Compare commits

...

8 Commits

Author SHA1 Message Date
AmanTahiliani
82abbd6b35 feat(#24): Rivalry compare view
Implemented by claude via .agents/dev dispatch.
2026-07-11 18:29:52 -04:00
Aman Tahiliani
09104451d0 Merge pull request #61 from AmanTahiliani/feat/issue-22-driver-profile-page
Driver profile page (#22)
2026-07-11 18:13:51 -04:00
AmanTahiliani
f8bd183c26 merge phase-2 (preview page, chapters) — resolve additive import conflicts 2026-07-11 18:13:41 -04:00
AmanTahiliani
01ec1f24e7 feat(#22): Driver profile page
Implemented by claude via .agents/dev dispatch.
2026-07-11 18:12:00 -04:00
AmanTahiliani
3f022a0cf9 fix(agents): quote skill YAML descriptions — strict parsers (cursor CLI) fail on embedded quotes/colons
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:10:34 -04:00
Aman Tahiliani
5970649a9c Merge pull request #60 from AmanTahiliani/feat/issue-19-auto-chapter-detection-rc-position-swing
Auto-chapter detection (RC + position swings) (#19)
2026-07-11 18:06:33 -04:00
Aman Tahiliani
785dccdca2 Merge pull request #59 from AmanTahiliani/feat/issue-25-next-race-preview-page
Next-race preview page (#25)
2026-07-11 17:59:51 -04:00
AmanTahiliani
e8de2068c1 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>
2026-07-11 17:59:00 -04:00
34 changed files with 3567 additions and 38 deletions

View File

@@ -1,6 +1,6 @@
--- ---
name: groom name: groom
description: Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval. description: 'Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.'
argument-hint: <issue-number> [--lens architect] argument-hint: <issue-number> [--lens architect]
--- ---

View File

@@ -1,6 +1,6 @@
--- ---
name: implement name: implement
description: Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev. description: 'Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.'
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run] argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
--- ---

View File

@@ -1,6 +1,6 @@
--- ---
name: lens-architect name: lens-architect
description: Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk. description: 'Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.'
argument-hint: <issue-number> argument-hint: <issue-number>
--- ---

View File

@@ -1,6 +1,6 @@
--- ---
name: review name: review
description: Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate. description: 'Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.'
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots] argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
--- ---

View File

@@ -1,6 +1,6 @@
--- ---
name: write-spec name: write-spec
description: Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue's spec from agreed decisions. Does not change Stage. description: 'Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue''s spec from agreed decisions. Does not change Stage.'
argument-hint: <issue-number> argument-hint: <issue-number>
--- ---

View File

@@ -2,6 +2,9 @@ import type {
ArticleContent, ArticleContent,
CarDataSample, CarDataSample,
ChampionshipHub, ChampionshipHub,
DriverSummary,
EnrichedGrid,
EnrichedResult,
LapsComparisonResponse, LapsComparisonResponse,
LiveStateResponse, LiveStateResponse,
LiveSessionMeta, LiveSessionMeta,
@@ -40,7 +43,11 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
} }
export async function fetchSeasonMeetings(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) { if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`) throw new Error(`API ${res.status}: ${res.statusText}`)
} }
@@ -48,6 +55,32 @@ export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
return Array.isArray(meetings) ? meetings : [] return Array.isArray(meetings) ? meetings : []
} }
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const results = await res.json()
return Array.isArray(results) ? results : []
}
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const grid = await res.json()
return Array.isArray(grid) ? grid : []
}
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
if (!res.ok) return null
const data = await res.json()
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
return data as TrackOutline
}
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> { export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`) const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
if (!res.ok) { if (!res.ok) {
@@ -76,6 +109,19 @@ export async function fetchChampionshipHub(year?: number): Promise<ChampionshipH
return res.json() return res.json()
} }
export async function fetchDriverSummary(
driverNumber: number,
year?: number,
): Promise<DriverSummary> {
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<LiveStateResponse> { export async function fetchLiveState(): Promise<LiveStateResponse> {
const res = await fetch('/api/v1/live/state') const res = await fetch('/api/v1/live/state')
if (!res.ok) { if (!res.ok) {

View File

@@ -16,6 +16,9 @@ export function Nav() {
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}> <Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub Race Hub
</Link> </Link>
<Link to="/preview" activeProps={{ className: 'active' }}>
Preview
</Link>
<Link to="/championship" activeProps={{ className: 'active' }}> <Link to="/championship" activeProps={{ className: 'active' }}>
Championship Championship
</Link> </Link>

View File

@@ -0,0 +1,271 @@
import { useMemo, useState } from 'react'
import type { ChampHubDriver, ChampionshipHub } from '../types'
import { teamColor } from '../utils'
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
import '../styles/rivalry.css'
const PAD_L = 48
const PAD_T = 16
const PLOT_W = 882
const PLOT_H = 316
const GAP_PLOT_H = 200
function fmtPts(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1)
}
/** Up to ~7 evenly spaced round labels, always including the last round. */
function xTicks(n: number, labels: string[], x: (i: number) => number): { x: number; label: string }[] {
const step = Math.max(1, Math.ceil(n / 7))
const ticks: { x: number; label: string }[] = []
for (let i = 0; i < n; i += step) {
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
}
const last = labels[n - 1] ?? `R${n}`
if (ticks[ticks.length - 1]?.label !== last) {
ticks.push({ x: x(n - 1), label: last })
}
return ticks
}
function polyline(values: number[], x: (i: number) => number, y: (v: number) => number): string {
return values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
}
interface PickerProps {
id: 'a' | 'b'
label: string
drivers: ChampHubDriver[]
value: number
color: string
onChange: (driverNumber: number) => void
}
function DriverPicker({ id, label, drivers, value, color, onChange }: PickerProps) {
return (
<label className="rivalry-picker">
<span className="rivalry-picker-label">{label}</span>
<select
className="rivalry-picker-select"
style={{ borderLeft: `3px solid ${color}` }}
value={value}
data-testid={`rivalry-pick-${id}`}
onChange={(e) => onChange(Number(e.target.value))}
>
{drivers.map((d) => (
<option key={d.driver_number} value={d.driver_number}>
{d.name_acronym} · {d.full_name}
</option>
))}
</select>
</label>
)
}
export function RivalryCompare({ hub }: { hub: ChampionshipHub }) {
const { drivers } = hub
const [pickA, setPickA] = useState<number | null>(null)
const [pickB, setPickB] = useState<number | null>(null)
// Default to the top two in the standings; fall back there if a picked
// driver disappears (e.g. season switch re-fetches the hub).
const a = drivers.find((d) => d.driver_number === pickA) ?? drivers[0]
const b = drivers.find((d) => d.driver_number === pickB) ?? drivers[1]
const tally = useMemo(
() => (a && b ? h2hTally(a.round_positions ?? [], b.round_positions ?? [], hub.round_labels) : null),
[a, b, hub.round_labels],
)
if (drivers.length < 2) {
return (
<div className="champ-chart-empty" data-testid="champ-view-rivalry">
Need at least two drivers in the standings to compare a rivalry.
</div>
)
}
const colorA = teamColor(a.team_colour)
const colorB = teamColor(b.team_colour)
const sameTeam = a.team_name === b.team_name
const cumA = a.cumulative ?? []
const cumB = b.cumulative ?? []
const rounds = Math.min(cumA.length, cumB.length)
const gaps = gapSeries(cumA, cumB)
const lastGap = gaps[gaps.length - 1] ?? 0
const strip = tally ? lastRounds(tally, 5) : []
const pickers = (
<div className="rivalry-pickers">
<DriverPicker id="a" label="Driver A" drivers={drivers} value={a.driver_number} color={colorA} onChange={setPickA} />
<span className="rivalry-vs mono">vs</span>
<DriverPicker id="b" label="Driver B" drivers={drivers} value={b.driver_number} color={colorB} onChange={setPickB} />
</div>
)
if (rounds === 0) {
return (
<div className="rivalry" data-testid="champ-view-rivalry">
{pickers}
<div className="champ-chart-empty">
No completed rounds yet the rivalry will appear after the first race.
</div>
</div>
)
}
// Points race scales.
const peak = Math.max(...cumA.slice(0, rounds), ...cumB.slice(0, rounds), 1)
const maxY = Math.max(50, Math.ceil(peak / 50) * 50)
const px = (i: number) => (rounds <= 1 ? PAD_L : PAD_L + (i * PLOT_W) / (rounds - 1))
const py = (v: number) => PAD_T + PLOT_H - (v / maxY) * PLOT_H
const pyGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ y: py(maxY * f), label: Math.round(maxY * f) }))
const pxGrid = xTicks(rounds, hub.round_labels, px)
// Gap scales: symmetric around zero.
const maxAbs = Math.max(10, Math.ceil(Math.max(...gaps.map(Math.abs), 1) / 10) * 10)
const gy = (v: number) => PAD_T + GAP_PLOT_H / 2 - (v / maxAbs) * (GAP_PLOT_H / 2)
const gyGrid = [maxAbs, 0, -maxAbs].map((v) => ({ y: gy(v), label: v > 0 ? `+${v}` : String(v) }))
const gxGrid = xTicks(rounds, hub.round_labels, px)
const gapLeader = lastGap === 0 ? null : lastGap > 0 ? a : b
const gapCaption = gapLeader
? `${gapLeader.name_acronym} leads by ${fmtPts(Math.abs(lastGap))} pts after ${hub.round_labels[rounds - 1] ?? `R${rounds}`}.`
: 'Dead level on points.'
return (
<div className="rivalry" data-testid="champ-view-rivalry">
{pickers}
<div className="rivalry-h2h" data-testid="rivalry-h2h">
<div className="rivalry-h2h-score">
<span className="rivalry-h2h-code mono" style={{ color: colorA }}>
{a.name_acronym}
</span>
<span className="rivalry-h2h-num mono" data-testid="rivalry-h2h-num">
{tally ? `${tally.a}${tally.b}` : '—'}
</span>
<span className="rivalry-h2h-code mono" style={{ color: colorB }}>
{b.name_acronym}
</span>
</div>
<span className="rivalry-h2h-meta">
Race head-to-head · {tally?.rounds.length ?? 0} round{(tally?.rounds.length ?? 0) === 1 ? '' : 's'} counted
{tally && tally.skipped > 0 ? ` · ${tally.skipped} skipped` : ''}
</span>
{strip.length > 0 && (
<div className="rivalry-strip" data-testid="rivalry-strip">
<span className="rivalry-strip-label">Last {strip.length}</span>
{strip.map((r) => (
<span className="rivalry-chip" key={r.round} title={`${r.label}: ${a.name_acronym} P${r.posA} · ${b.name_acronym} P${r.posB}`}>
<span className="rivalry-chip-round mono">{r.label}</span>
<span className="rivalry-chip-winner mono" style={{ color: r.winner === 'a' ? colorA : colorB }}>
{r.winner === 'a' ? a.name_acronym : b.name_acronym}
</span>
</span>
))}
</div>
)}
</div>
<section className="rivalry-section" data-testid="rivalry-points-race">
<div className="champ-chart-head">
<span className="champ-chart-title mono">
Points race {a.name_acronym} vs {b.name_acronym}
</span>
<span className="champ-chart-meta">
Rounds 1{rounds} · {hub.season}
</span>
</div>
<div className="champ-chart">
<svg viewBox="0 0 1000 380" className="champ-chart-svg" preserveAspectRatio="none">
{pyGrid.map((g) => (
<g key={g.label}>
<line x1={PAD_L} y1={g.y} x2={930} y2={g.y} stroke="var(--border)" strokeWidth="1" />
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
{g.label}
</text>
</g>
))}
{pxGrid.map((g, i) => (
<text key={i} x={g.x} y={372} textAnchor="middle" className="champ-chart-axis">
{g.label}
</text>
))}
{[
{ slot: 'a', d: a, color: colorA, dash: '0', values: cumA.slice(0, rounds) },
{ slot: 'b', d: b, color: colorB, dash: sameTeam ? '5 4' : '0', values: cumB.slice(0, rounds) },
].map((s) => (
<g key={s.slot}>
<polyline
points={polyline(s.values, px, py)}
fill="none"
stroke={s.color}
strokeWidth="2.4"
strokeDasharray={s.dash}
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={px(s.values.length - 1)} cy={py(s.values[s.values.length - 1])} r="3" fill={s.color} />
<text
x={px(s.values.length - 1) + 8}
y={py(s.values[s.values.length - 1]) + 4}
fill={s.color}
className="champ-chart-label"
>
{s.d.name_acronym}
</text>
</g>
))}
</svg>
</div>
</section>
<section className="rivalry-section" data-testid="rivalry-gap">
<div className="champ-chart-head">
<span className="champ-chart-title mono">
Gap over season {a.name_acronym} {b.name_acronym}
</span>
<span className="champ-chart-meta">{gapCaption}</span>
</div>
<div className="champ-chart">
<svg viewBox="0 0 1000 250" className="champ-chart-svg" preserveAspectRatio="none">
{gyGrid.map((g) => (
<g key={g.label}>
<line
x1={PAD_L}
y1={g.y}
x2={930}
y2={g.y}
stroke={g.label === '0' ? 'var(--border-2)' : 'var(--border)'}
strokeWidth={g.label === '0' ? 1.5 : 1}
/>
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
{g.label}
</text>
</g>
))}
{gxGrid.map((g, i) => (
<text key={i} x={g.x} y={242} textAnchor="middle" className="champ-chart-axis">
{g.label}
</text>
))}
<polyline
points={polyline(gaps, px, gy)}
fill="none"
stroke={colorA}
strokeWidth="2.4"
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={px(gaps.length - 1)} cy={gy(lastGap)} r="3" fill={colorA} />
</svg>
</div>
<p className="rivalry-caption">
Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead.
</p>
</section>
</div>
)
}

View File

@@ -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 '='
}

View File

@@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string {
return name.slice(0, 3).toUpperCase() || '—' return name.slice(0, 3).toUpperCase() || '—'
} }
export function countryFlag(meeting: Meeting | undefined | null): string { export function countryFlag(
meeting: Pick<Meeting, 'country_code' | 'country_flag'> | undefined | null,
): string {
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
const code = meeting?.country_code?.toUpperCase() const code = meeting?.country_code?.toUpperCase()
const iso2 = code ? CODE3_TO_2[code] : '' const iso2 = code ? CODE3_TO_2[code] : ''

123
frontend/src/lib/preview.ts Normal file
View 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)}`
}

View File

@@ -0,0 +1,61 @@
// Pure helpers for the championship rivalry compare view.
/** One round that counted toward the head-to-head tally. */
export interface H2HRound {
round: number // 1-based season round
label: string
posA: number
posB: number
winner: 'a' | 'b'
}
export interface H2HTally {
a: number
b: number
/** Rounds counted in the tally, in season order. */
rounds: H2HRound[]
/** Rounds skipped because either driver had no finishing position. */
skipped: number
}
/**
* Per-round points gap (a b) across the rounds both cumulative series
* cover. Positive values mean driver A is ahead.
*/
export function gapSeries(a: number[], b: number[]): number[] {
const n = Math.min(a.length, b.length)
const out: number[] = []
for (let i = 0; i < n; i++) out.push(a[i] - b[i])
return out
}
/**
* Race head-to-head: per round, the lower finishing position wins. Rounds
* where either driver has no position (0 / missing) are skipped from the
* tally, as are equal positions (defensive — races can't tie).
*/
export function h2hTally(posA: number[], posB: number[], labels: string[]): H2HTally {
const n = Math.max(posA.length, posB.length)
const rounds: H2HRound[] = []
let a = 0
let b = 0
let skipped = 0
for (let i = 0; i < n; i++) {
const pa = posA[i] ?? 0
const pb = posB[i] ?? 0
if (pa <= 0 || pb <= 0 || pa === pb) {
skipped++
continue
}
const winner = pa < pb ? 'a' : 'b'
if (winner === 'a') a++
else b++
rounds.push({ round: i + 1, label: labels[i] ?? `R${i + 1}`, posA: pa, posB: pb, winner })
}
return { a, b, rounds, skipped }
}
/** Last n counted rounds of a tally, for the mini strip. */
export function lastRounds(tally: H2HTally, n: number): H2HRound[] {
return tally.rounds.slice(-n)
}

View File

@@ -1,13 +1,15 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchChampionshipHub, fetchSeasons } from '../api' import { fetchChampionshipHub, fetchSeasons } from '../api'
import { teamColor } from '../utils' import { teamColor } from '../utils'
import type { ChampHubDriver, ChampionshipHub } from '../types' import type { ChampHubDriver, ChampionshipHub } from '../types'
import { ChampionshipSimulator } from '../components/ChampionshipSimulator' import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
import { RivalryCompare } from '../components/RivalryCompare'
import { Meaning } from '../components/Meaning' import { Meaning } from '../components/Meaning'
import { pointsGapMeaning } from '../lib/meaning' import { pointsGapMeaning } from '../lib/meaning'
type View = 'drivers' | 'constructors' | 'progression' | 'simulator' type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
const GOLD = '#ffd700' const GOLD = '#ffd700'
const SILVER = '#c0c0c0' const SILVER = '#c0c0c0'
@@ -213,6 +215,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
> >
Progression Progression
</button> </button>
<button
type="button"
className={`champ-tab${view === 'rivalry' ? ' is-active' : ''}`}
onClick={() => setView('rivalry')}
data-testid="champ-tab-rivalry"
>
Rivalry
</button>
<button <button
type="button" type="button"
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`} className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
@@ -242,10 +252,12 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
leaderPoints={leader.points} leaderPoints={leader.points}
titleMath={titleMath} titleMath={titleMath}
roundsLeft={hub.rounds_left} roundsLeft={hub.rounds_left}
season={hub.season}
/> />
)} )}
{view === 'constructors' && <ConstructorsView hub={hub} />} {view === 'constructors' && <ConstructorsView hub={hub} />}
{view === 'progression' && <ProgressionView hub={hub} />} {view === 'progression' && <ProgressionView hub={hub} />}
{view === 'rivalry' && <RivalryCompare hub={hub} />}
{view === 'simulator' && <ChampionshipSimulator hub={hub} />} {view === 'simulator' && <ChampionshipSimulator hub={hub} />}
</div> </div>
) )
@@ -271,11 +283,13 @@ function DriversView({
leaderPoints, leaderPoints,
titleMath, titleMath,
roundsLeft, roundsLeft,
season,
}: { }: {
enriched: EnrichedDriver[] enriched: EnrichedDriver[]
leaderPoints: number leaderPoints: number
titleMath: string titleMath: string
roundsLeft: number roundsLeft: number
season: number
}) { }) {
const podium = enriched.slice(0, 3) const podium = enriched.slice(0, 3)
return ( return (
@@ -360,12 +374,19 @@ function DriversView({
P{e.pos} P{e.pos}
</td> </td>
<td> <td>
<div className="champ-drv"> <Link
to="/drivers/$driverNumber"
params={{ driverNumber: String(e.d.driver_number) }}
search={{ year: season }}
className="champ-drv"
style={{ color: 'inherit', textDecoration: 'none' }}
data-testid={`champ-driver-link-${e.d.driver_number}`}
>
<span className="champ-drv-bar" style={{ background: e.color }} /> <span className="champ-drv-bar" style={{ background: e.color }} />
<span className="champ-drv-code mono">{e.d.name_acronym}</span> <span className="champ-drv-code mono">{e.d.name_acronym}</span>
<span className="champ-drv-name">{e.d.full_name}</span> <span className="champ-drv-name">{e.d.full_name}</span>
<span className="champ-drv-num mono">#{e.d.driver_number}</span> <span className="champ-drv-num mono">#{e.d.driver_number}</span>
</div> </Link>
</td> </td>
<td className="champ-td-team">{e.d.team_name}</td> <td className="champ-td-team">{e.d.team_name}</td>
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td> <td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>

View File

@@ -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 <div className="page error-box">Invalid driver number</div>
}
if (!seasonsSettled || summaryQuery.isLoading) {
return <div className="page loading-state">loading driver profile</div>
}
if (summaryQuery.isError) {
return (
<div className="page error-box">
{summaryQuery.error instanceof Error
? summaryQuery.error.message
: 'Failed to load driver profile'}
</div>
)
}
const summary = summaryQuery.data
if (!summary) {
return <div className="page error-box">No driver data</div>
}
return <DriverProfileBody summary={summary} />
}
function DriverProfileBody({ summary }: { summary: DriverSummary }) {
const color = teamColor(summary.team_colour)
const deltas = gridFinishDeltas(summary.rounds)
return (
<div className="dp-page" data-testid="driver-profile">
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
<div className="dp-identity">
<div className="dp-title-row">
<span className="dp-code mono">{summary.name_acronym || `#${summary.driver_number}`}</span>
<span className="dp-num mono" style={{ color }}>
#{summary.driver_number}
</span>
</div>
<h1 className="dp-name">{summary.full_name || `Driver ${summary.driver_number}`}</h1>
<div className="dp-team">
<span className="dp-team-swatch" style={{ background: color }} />
{summary.team_name || 'Unknown team'}
<span className="dp-season mono">{summary.season}</span>
</div>
</div>
<div className="dp-stat-rail">
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.position > 0 ? `P${summary.position}` : '—'}</div>
<div className="dp-stat-label">Championship</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{fmtPts(summary.points)}</div>
<div className="dp-stat-label">Points</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.wins}</div>
<div className="dp-stat-label">Wins</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.podiums}</div>
<div className="dp-stat-label">Podiums</div>
</div>
<div className="dp-stat">
<div className="dp-stat-value mono">{summary.poles}</div>
<div className="dp-stat-label">Poles</div>
</div>
</div>
<Link to="/championship" className="dp-back mono">
Championship
</Link>
</header>
<section className="dp-section" data-testid="dp-form">
<h2 className="dp-section-title mono">Season form</h2>
{summary.cumulative.length > 0 ? (
<div className="dp-form-grid">
<CumulativeLine
cumulative={summary.cumulative}
labels={summary.round_labels}
color={color}
/>
<FormStrip form={summary.form} color={color} />
</div>
) : (
<div className="dp-empty">No completed rounds yet.</div>
)}
</section>
<section className="dp-section" data-testid="dp-quali-race">
<h2 className="dp-section-title mono">Quali vs race</h2>
{deltas.some((d) => d.delta != null) ? (
<GridVsRaceChart deltas={deltas} color={color} />
) : (
<div className="dp-empty">No grid-vs-finish data yet.</div>
)}
</section>
<section className="dp-section" data-testid="dp-rounds">
<h2 className="dp-section-title mono">Track by track</h2>
{summary.rounds.length > 0 ? (
<RoundsTable summary={summary} deltas={deltas} />
) : (
<div className="dp-empty">No completed rounds yet.</div>
)}
</section>
</div>
)
}
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 (
<figure className="dp-chart">
<figcaption className="dp-chart-caption mono">Cumulative points</figcaption>
<svg viewBox={`0 0 ${LINE_W} ${LINE_H}`} className="dp-chart-svg" role="img" aria-label="Cumulative points per round">
{[0, 0.5, 1].map((f) => {
const v = Math.round(maxY * f)
return (
<g key={f}>
<line x1={LINE_PL} y1={y(v)} x2={LINE_W - LINE_PR} y2={y(v)} className="dp-gridline" />
<text x={LINE_PL - 6} y={y(v) + 3} textAnchor="end" className="dp-axis">
{v}
</text>
</g>
)
})}
{ticks.map((t) => (
<text key={t.label} x={t.x} y={LINE_H - 8} textAnchor="middle" className="dp-axis">
{t.label}
</text>
))}
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth="2.2"
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={x(n - 1)} cy={y(cumulative[n - 1])} r="3.2" fill={color} />
</svg>
</figure>
)
}
function FormStrip({ form, color }: { form: number[]; color: string }) {
const max = Math.max(25, ...form)
return (
<div className="dp-form-strip">
<div className="dp-chart-caption mono">Last {form.length} races</div>
<div className="dp-form-bars">
{form.map((pts, i) => (
<div className="dp-form-bar" key={i} title={`${fmtPts(pts)} pts`}>
<div
className="dp-form-bar-fill"
style={{ height: `${Math.max(4, (pts / max) * 100)}%`, background: color }}
/>
<span className="dp-form-bar-val mono">{fmtPts(pts)}</span>
</div>
))}
</div>
</div>
)
}
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 (
<figure className="dp-chart">
<figcaption className="dp-chart-caption mono">
Positions gained () / lost () from grid to flag, per round
</figcaption>
<div className="dp-chart-scroll">
<svg
viewBox={`0 0 ${w} ${DELTA_H}`}
width={w}
height={DELTA_H}
className="dp-delta-svg"
role="img"
aria-label="Positions gained or lost per round"
>
<line x1={0} y1={zeroY} x2={w} y2={zeroY} className="dp-zeroline" />
{deltas.map((d, i) => {
const cx = i * colW + colW / 2
if (d.delta == null) {
return (
<g key={d.round}>
<text x={cx} y={zeroY - 6} textAnchor="middle" className="dp-axis">
·
</text>
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
R{d.round}
</text>
</g>
)
}
const h = Math.abs(d.delta) * scale
const yTop = d.delta >= 0 ? zeroY - h : zeroY
return (
<g key={d.round}>
<title>{`${d.label}: ${formatPosition(d.grid)}${formatPosition(d.finish)} (${formatDelta(d.delta)})`}</title>
<rect
x={cx - 8}
y={yTop}
width={16}
height={Math.max(1.5, h)}
rx={2}
fill={color}
opacity={d.delta >= 0 ? 0.95 : 0.45}
/>
<text
x={cx}
y={d.delta >= 0 ? yTop - 4 : yTop + h + 11}
textAnchor="middle"
className="dp-delta-val mono"
>
{formatDelta(d.delta)}
</text>
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
R{d.round}
</text>
</g>
)
})}
</svg>
</div>
</figure>
)
}
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 (
<div className="dp-scroll">
<table className="dp-table">
<thead>
<tr>
<th className="l">Rnd</th>
<th className="l">Grand Prix</th>
<th className="r">Grid</th>
<th className="r">Race</th>
<th className="r">Δ</th>
<th className="r">Pts</th>
<th className="l"></th>
</tr>
</thead>
<tbody>
{summary.rounds.map((r, i) => {
const d = deltas[i]
return (
<tr key={r.meeting_key}>
<td className="mono dp-td-dim">R{i + 1}</td>
<td>
<span className="dp-flag" aria-hidden="true">
{countryFlag({ country_code: r.country_code, country_flag: '' })}
</span>
{r.meeting_name}
</td>
<td className="r mono">{formatPosition(d.grid)}</td>
<td className="r mono">{formatPosition(d.finish)}</td>
<td
className="r mono"
style={{
color:
d.delta == null
? 'var(--text-3)'
: d.delta > 0
? 'var(--green)'
: d.delta < 0
? 'var(--red)'
: 'var(--text-2)',
}}
>
{formatDelta(d.delta)}
</td>
<td className="r mono">{fmtPts(r.points)}</td>
<td className="dp-td-dim mono">{statusNote(d)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View 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&apos; 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>
)
}

View File

@@ -6,11 +6,17 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage' import { LiveTimingPage } from './pages/LiveTimingPage'
import { BriefingPage } from './pages/BriefingPage' import { BriefingPage } from './pages/BriefingPage'
import { ChampionshipPage } from './pages/ChampionshipPage' import { ChampionshipPage } from './pages/ChampionshipPage'
import { DriverProfilePage } from './pages/DriverProfilePage'
import { RacePreviewPage } from './pages/RacePreviewPage'
type RaceHubSearch = { type RaceHubSearch = {
session_key?: number session_key?: number
} }
type DriverProfileSearch = {
year?: number
}
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
component: () => ( component: () => (
<> <>
@@ -64,12 +70,32 @@ export const championshipRoute = createRoute({
component: ChampionshipPage, component: ChampionshipPage,
}) })
export const driverProfileRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/drivers/$driverNumber',
validateSearch: (search: Record<string, unknown>): 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 <DriverProfilePage driverNumber={Number(driverNumber) || 0} year={year} />
},
})
export const briefingRoute = createRoute({ export const briefingRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/briefing', path: '/briefing',
component: BriefingPage, component: BriefingPage,
}) })
export const previewRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/preview',
component: RacePreviewPage,
})
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
commandCenterRoute, commandCenterRoute,
raceHubRoute, raceHubRoute,
@@ -77,7 +103,9 @@ const routeTree = rootRoute.addChildren([
dataLibraryRoute, dataLibraryRoute,
liveTimingRoute, liveTimingRoute,
championshipRoute, championshipRoute,
driverProfileRoute,
briefingRoute, briefingRoute,
previewRoute,
]) ])
export const router = createRouter({ routeTree }) export const router = createRouter({ routeTree })

View File

@@ -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);
}

View 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;
}
}

View File

@@ -0,0 +1,134 @@
.rivalry {
display: flex;
flex-direction: column;
gap: var(--s5);
}
.rivalry-pickers {
display: flex;
flex-wrap: wrap;
gap: var(--s4);
align-items: flex-end;
}
.rivalry-vs {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
padding-bottom: var(--s3);
}
.rivalry-picker {
display: flex;
flex-direction: column;
gap: var(--s2);
min-width: 200px;
}
.rivalry-picker-label {
font-family: var(--f-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
}
.rivalry-picker-select {
font-family: var(--f-mono);
font-size: 13px;
padding: var(--s2) var(--s3);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-2);
color: var(--text);
}
.rivalry-picker-select:focus {
outline: 2px solid var(--red);
outline-offset: 1px;
}
.rivalry-h2h {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s4) var(--s5);
padding: var(--s4) var(--s5);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-2);
}
.rivalry-h2h-score {
display: flex;
align-items: baseline;
gap: var(--s3);
}
.rivalry-h2h-code {
font-size: 14px;
font-weight: 700;
}
.rivalry-h2h-num {
font-size: 24px;
font-weight: 700;
color: var(--text);
}
.rivalry-h2h-meta {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
}
.rivalry-strip {
display: flex;
align-items: center;
gap: var(--s2);
margin-left: auto;
}
.rivalry-strip-label {
font-family: var(--f-mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
margin-right: var(--s2);
}
.rivalry-chip {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
padding: var(--s1) var(--s3);
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
}
.rivalry-chip-round {
font-size: 9px;
color: var(--text-3);
}
.rivalry-chip-winner {
font-size: 11px;
font-weight: 700;
}
.rivalry-section {
display: flex;
flex-direction: column;
gap: var(--s3);
}
.rivalry-caption {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
margin: 0;
}

View File

@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 4, poles: 4,
form: [25, 18, 25, 15, 25], form: [25, 18, 25, 15, 25],
cumulative: [25, 43, 68, 83, 108, 200], cumulative: [25, 43, 68, 83, 108, 200],
round_positions: [1, 2, 1, 3, 1, 1],
teammate_wins: 9, teammate_wins: 9,
teammate_losses: 1, teammate_losses: 1,
...over, ...over,
@@ -47,6 +48,7 @@ const drivers: ChampHubDriver[] = [
position: 2, position: 2,
wins: 3, wins: 3,
cumulative: [18, 36, 54, 80, 120, 160], cumulative: [18, 36, 54, 80, 120, 160],
round_positions: [2, 1, 2, 2, 2, 2],
}), }),
driver({ driver({
driver_number: 16, driver_number: 16,
@@ -58,6 +60,7 @@ const drivers: ChampHubDriver[] = [
position: 3, position: 3,
wins: 1, wins: 1,
cumulative: [15, 28, 40, 60, 90, 120], cumulative: [15, 28, 40, 60, 90, 120],
round_positions: [3, 3, 3, 0, 3, 3],
}), }),
] ]
@@ -129,6 +132,22 @@ describe('ChampionshipPage', () => {
expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument() expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument()
}) })
it('switches to the rivalry view with the top two drivers preselected', async () => {
renderPage()
await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('champ-tab-rivalry'))
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
// Default pair is the top two in the standings: VER vs NOR.
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
// VER beats NOR in rounds 1, 3, 5, 6 → 42.
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('42')
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
})
it('switches to the simulator view and projects standings', async () => { it('switches to the simulator view and projects standings', async () => {
renderPage() renderPage()

View File

@@ -17,6 +17,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 0, poles: 0,
form: [], form: [],
cumulative: [], cumulative: [],
round_positions: [],
teammate_wins: 0, teammate_wins: 0,
teammate_losses: 0, teammate_losses: 0,
...over, ...over,

View File

@@ -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: () => (
<QueryClientProvider client={queryClient}>
<DriverProfilePage driverNumber={props.driverNumber} year={props.year} />
</QueryClientProvider>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => null,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
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()
})
})

View File

@@ -0,0 +1,277 @@
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],
round_positions: [1],
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')
})
})
})

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { RivalryCompare } from '../components/RivalryCompare'
import type { ChampHubDriver, ChampionshipHub } from '../types'
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
return {
driver_number: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull',
team_colour: '3671c6',
points: 100,
position: 1,
wins: 2,
podiums: 3,
poles: 1,
form: [25, 18, 25],
cumulative: [25, 43, 68],
round_positions: [1, 2, 1],
teammate_wins: 3,
teammate_losses: 0,
...over,
}
}
function makeHub(drivers: ChampHubDriver[]): ChampionshipHub {
return {
season: 2025,
round: 3,
total_rounds: 10,
rounds_left: 7,
last_race: 'Japan GP',
round_labels: ['R1', 'R2', 'R3'],
drivers,
teams: [],
}
}
const hub = makeHub([
driver({}),
driver({
driver_number: 4,
name_acronym: 'NOR',
full_name: 'Lando Norris',
team_name: 'McLaren',
team_colour: 'ff8000',
points: 90,
position: 2,
cumulative: [18, 43, 61],
round_positions: [2, 1, 2],
}),
driver({
driver_number: 16,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'e8002d',
points: 50,
position: 3,
cumulative: [15, 28, 40],
round_positions: [3, 0, 3],
}),
])
describe('RivalryCompare', () => {
it('defaults to the top two drivers and shows the H2H score', () => {
render(<RivalryCompare hub={hub} />)
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
// VER wins R1 and R3, NOR wins R2.
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('21')
expect(screen.getByText('3 rounds counted', { exact: false })).toBeInTheDocument()
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
// Last gap: 68 61 = +7 → VER ahead.
expect(screen.getByText('VER leads by 7 pts', { exact: false })).toBeInTheDocument()
})
it('recomputes when a different driver is picked and skips missing rounds', () => {
render(<RivalryCompare hub={hub} />)
fireEvent.change(screen.getByTestId('rivalry-pick-b'), { target: { value: '16' } })
// VER vs LEC: R2 skipped (LEC has no position), VER wins R1 and R3.
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('20')
expect(screen.getByText('2 rounds counted · 1 skipped', { exact: false })).toBeInTheDocument()
})
it('shows an empty state with fewer than two drivers', () => {
render(<RivalryCompare hub={makeHub([driver({})])} />)
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('at least two drivers')
})
it('shows an empty message when no rounds are completed', () => {
const empty = makeHub([
driver({ cumulative: [], round_positions: [], form: [] }),
driver({
driver_number: 4,
name_acronym: 'NOR',
cumulative: [],
round_positions: [],
form: [],
}),
])
empty.round = 0
empty.round_labels = []
render(<RivalryCompare hub={empty} />)
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('No completed rounds yet')
})
})

View File

@@ -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>): 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('—')
})
})

View File

@@ -0,0 +1,227 @@
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],
round_positions: [1],
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)
})
})

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
describe('gapSeries', () => {
it('computes per-round a b', () => {
expect(gapSeries([25, 43, 68], [18, 36, 54])).toEqual([7, 7, 14])
})
it('handles negative gaps (b ahead)', () => {
expect(gapSeries([10, 20], [18, 36])).toEqual([-8, -16])
})
it('truncates to the shorter series', () => {
expect(gapSeries([25, 43, 68], [18])).toEqual([7])
expect(gapSeries([], [18, 36])).toEqual([])
})
})
describe('h2hTally', () => {
it('tallies lower-position wins per round', () => {
const t = h2hTally([1, 2, 1], [2, 1, 3], ['R1', 'R2', 'R3'])
expect(t.a).toBe(2)
expect(t.b).toBe(1)
expect(t.skipped).toBe(0)
expect(t.rounds.map((r) => r.winner)).toEqual(['a', 'b', 'a'])
expect(t.rounds[0]).toEqual({ round: 1, label: 'R1', posA: 1, posB: 2, winner: 'a' })
})
it('skips rounds where either driver has no position', () => {
// R2: a missing (0). R3: b missing (0). Only R1 and R4 count.
const t = h2hTally([1, 0, 5, 3], [4, 2, 0, 1], ['R1', 'R2', 'R3', 'R4'])
expect(t.a).toBe(1)
expect(t.b).toBe(1)
expect(t.skipped).toBe(2)
expect(t.rounds.map((r) => r.round)).toEqual([1, 4])
})
it('handles arrays of different lengths (missing tail = skipped)', () => {
const t = h2hTally([1, 2, 3], [2], ['R1', 'R2', 'R3'])
expect(t.a).toBe(1)
expect(t.b).toBe(0)
expect(t.skipped).toBe(2)
})
it('skips equal positions defensively and falls back on labels', () => {
const t = h2hTally([2, 1], [2, 3], ['R1'])
expect(t.a).toBe(1)
expect(t.b).toBe(0)
expect(t.skipped).toBe(1)
expect(t.rounds[0].label).toBe('R2')
})
it('returns an empty tally for empty inputs', () => {
const t = h2hTally([], [], [])
expect(t).toEqual({ a: 0, b: 0, rounds: [], skipped: 0 })
})
})
describe('lastRounds', () => {
it('returns the last n counted rounds in order', () => {
const t = h2hTally([1, 1, 1, 2, 1, 1, 2], [2, 2, 2, 1, 2, 2, 1], [])
const last = lastRounds(t, 5)
expect(last).toHaveLength(5)
expect(last.map((r) => r.round)).toEqual([3, 4, 5, 6, 7])
})
it('returns fewer when the tally has fewer counted rounds', () => {
const t = h2hTally([1, 0], [2, 0], [])
expect(lastRounds(t, 5)).toHaveLength(1)
})
})

View File

@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
poles: 0, poles: 0,
form: [], form: [],
cumulative: [], cumulative: [],
round_positions: [],
teammate_wins: 0, teammate_wins: 0,
teammate_losses: 0, teammate_losses: 0,
...over, ...over,

View File

@@ -12,6 +12,7 @@ export interface Meeting {
country_name: string country_name: string
country_code: string country_code: string
country_flag: string country_flag: string
circuit_key?: number
circuit_short_name: string circuit_short_name: string
date_start: string date_start: string
date_end: string date_end: string
@@ -23,6 +24,7 @@ export interface Session {
session_key: number session_key: number
session_name: string session_name: string
session_type: string session_type: string
circuit_key?: number
meeting_key: number meeting_key: number
date_start: string date_start: string
date_end: string date_end: string
@@ -371,6 +373,7 @@ export interface ChampHubDriver {
poles: number poles: number
form: number[] form: number[]
cumulative: number[] cumulative: number[]
round_positions: number[]
teammate_wins: number teammate_wins: number
teammate_losses: number teammate_losses: number
} }
@@ -394,6 +397,39 @@ export interface ChampionshipHub {
teams: ChampHubTeam[] 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 { export interface NewsItem {
source: string source: string
title: string title: string

View File

@@ -616,6 +616,7 @@ type champHubDriver struct {
Poles int `json:"poles"` Poles int `json:"poles"`
Form []float64 `json:"form"` // last 5 races' points Form []float64 `json:"form"` // last 5 races' points
Cumulative []float64 `json:"cumulative"` // running total per completed round Cumulative []float64 `json:"cumulative"` // running total per completed round
RoundPositions []int `json:"round_positions"` // finishing position per completed round (0 = no result)
TeammateWins int `json:"teammate_wins"` TeammateWins int `json:"teammate_wins"`
TeammateLosses int `json:"teammate_losses"` TeammateLosses int `json:"teammate_losses"`
} }
@@ -797,11 +798,6 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
} }
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, 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) champ, err := s.client.GetDriverChampionshipForYear(year)
if err != nil { if err != nil {
return champHubResponse{}, err return champHubResponse{}, err
@@ -816,16 +812,39 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
driverInfo = buildDriverMapFirst(ds) driverInfo = buildDriverMapFirst(ds)
} }
sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart }) 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 // Any per-meeting fetch failure (network, rate limit) yields an incomplete
// aggregate: serve it so the page still renders, but cache it only briefly // 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. // so a partial view of the season doesn't stick around for the full TTL.
var incomplete atomic.Bool ttl = champHubIncompleteTTL
races := fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) { }
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 })
var failed atomic.Bool
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey)) sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
if serr != nil { if serr != nil {
incomplete.Store(true) failed.Store(true)
return meetingRace{}, false return meetingRace{}, false
} }
raceKey := 0 raceKey := 0
@@ -841,18 +860,11 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
results, rerr := s.client.GetSessionResult(raceKey) results, rerr := s.client.GetSessionResult(raceKey)
grid, gerr := s.client.GetStartingGrid(raceKey) grid, gerr := s.client.GetStartingGrid(raceKey)
if rerr != nil || gerr != nil { if rerr != nil || gerr != nil {
incomplete.Store(true) failed.Store(true)
} }
return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true
}) })
return races, failed.Load(), nil
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
} }
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be // aggregateChampionshipHub is the pure aggregation core (no network) so it can be
@@ -984,6 +996,10 @@ func aggregateChampionshipHub(
if len(form) > 5 { if len(form) > 5 {
form = form[len(form)-5:] form = form[len(form)-5:]
} }
roundPositions := make([]int, completed)
for round := 1; round <= completed; round++ {
roundPositions[round-1] = a.finishByRound[round]
}
info := driverInfo[c.DriverNumber] info := driverInfo[c.DriverNumber]
drivers = append(drivers, champHubDriver{ drivers = append(drivers, champHubDriver{
DriverNumber: c.DriverNumber, DriverNumber: c.DriverNumber,
@@ -998,6 +1014,7 @@ func aggregateChampionshipHub(
Poles: a.poles, Poles: a.poles,
Form: form, Form: form,
Cumulative: cumulative[c.DriverNumber], Cumulative: cumulative[c.DriverNumber],
RoundPositions: roundPositions,
TeammateWins: twins[c.DriverNumber], TeammateWins: twins[c.DriverNumber],
TeammateLosses: tloss[c.DriverNumber], TeammateLosses: tloss[c.DriverNumber],
}) })

View File

@@ -17,20 +17,22 @@ func TestAggregateChampionshipHub(t *testing.T) {
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6"}, 1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6"},
2: {DriverNumber: 2, NameAcronym: "PER", FullName: "Sergio Perez", TeamName: "Red Bull", TeamColour: "3671c6"}, 2: {DriverNumber: 2, NameAcronym: "PER", FullName: "Sergio Perez", TeamName: "Red Bull", TeamColour: "3671c6"},
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"}, 3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
4: {DriverNumber: 4, NameAcronym: "NOR", FullName: "Lando Norris", TeamName: "McLaren", TeamColour: "ff8000"},
} }
champ := []models.ChampionshipDriver{ champ := []models.ChampionshipDriver{
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99}, {DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99}, {DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
{DriverNumber: 2, PointsCurrent: 30, PositionCurrent: 3, SessionKey: 99}, {DriverNumber: 2, PointsCurrent: 30, PositionCurrent: 3, SessionKey: 99},
{DriverNumber: 4, PointsCurrent: 12, PositionCurrent: 4, SessionKey: 99},
} }
teams := []models.ChampionshipTeam{ teams := []models.ChampionshipTeam{
{TeamName: "Red Bull", PointsCurrent: 80, PositionCurrent: 1}, {TeamName: "Red Bull", PointsCurrent: 80, PositionCurrent: 1},
{TeamName: "Mercedes", PointsCurrent: 33, PositionCurrent: 2}, {TeamName: "Mercedes", PointsCurrent: 33, PositionCurrent: 2},
} }
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER. // Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER. NOR absent.
// Round 2: VER P1(25), PER P2(18), HAM P3(15). Pole: HAM. // Round 2: VER P1(25), PER P2(18), HAM P3(15), NOR P4(12). Pole: HAM.
races := []meetingRace{ races := []meetingRace{
{ {
Meeting: models.Meeting{MeetingName: "Bahrain GP"}, Meeting: models.Meeting{MeetingName: "Bahrain GP"},
@@ -39,7 +41,7 @@ func TestAggregateChampionshipHub(t *testing.T) {
}, },
{ {
Meeting: models.Meeting{MeetingName: "Saudi GP"}, Meeting: models.Meeting{MeetingName: "Saudi GP"},
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15)}, Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15), raceResult(4, 4, 12)},
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}}, Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}},
}, },
// Round 3: not yet run (no results) — should not count as completed. // Round 3: not yet run (no results) — should not count as completed.
@@ -67,9 +69,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels) t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
} }
// Drivers are sorted by official position: VER, HAM, PER. // Drivers are sorted by official position: VER, HAM, PER, NOR.
if len(resp.Drivers) != 3 { if len(resp.Drivers) != 4 {
t.Fatalf("drivers = %d, want 3", len(resp.Drivers)) t.Fatalf("drivers = %d, want 4", len(resp.Drivers))
} }
ver := resp.Drivers[0] ver := resp.Drivers[0]
if ver.NameAcronym != "VER" || ver.Position != 1 { if ver.NameAcronym != "VER" || ver.Position != 1 {
@@ -91,6 +93,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
if len(ver.Cumulative) != 2 || ver.Cumulative[0] != 25 || ver.Cumulative[1] != 50 { if len(ver.Cumulative) != 2 || ver.Cumulative[0] != 25 || ver.Cumulative[1] != 50 {
t.Errorf("VER cumulative = %v, want [25 50]", ver.Cumulative) t.Errorf("VER cumulative = %v, want [25 50]", ver.Cumulative)
} }
if len(ver.RoundPositions) != 2 || ver.RoundPositions[0] != 1 || ver.RoundPositions[1] != 1 {
t.Errorf("VER round positions = %v, want [1 1]", ver.RoundPositions)
}
// VER beat teammate PER in both rounds. // VER beat teammate PER in both rounds.
if ver.TeammateWins != 2 || ver.TeammateLosses != 0 { if ver.TeammateWins != 2 || ver.TeammateLosses != 0 {
t.Errorf("VER h2h = %d-%d, want 2-0", ver.TeammateWins, ver.TeammateLosses) t.Errorf("VER h2h = %d-%d, want 2-0", ver.TeammateWins, ver.TeammateLosses)
@@ -109,6 +114,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
if per.Poles != 0 { if per.Poles != 0 {
t.Errorf("PER poles = %d, want 0", per.Poles) t.Errorf("PER poles = %d, want 0", per.Poles)
} }
if len(per.RoundPositions) != 2 || per.RoundPositions[0] != 3 || per.RoundPositions[1] != 2 {
t.Errorf("PER round positions = %v, want [3 2]", per.RoundPositions)
}
// HAM has no teammate in the data — no h2h recorded. // HAM has no teammate in the data — no h2h recorded.
var ham champHubDriver var ham champHubDriver
@@ -124,6 +132,17 @@ func TestAggregateChampionshipHub(t *testing.T) {
t.Errorf("HAM poles = %d, want 1", ham.Poles) t.Errorf("HAM poles = %d, want 1", ham.Poles)
} }
// NOR missed round 1 — position 0 marks the absent round.
var nor champHubDriver
for _, d := range resp.Drivers {
if d.NameAcronym == "NOR" {
nor = d
}
}
if len(nor.RoundPositions) != 2 || nor.RoundPositions[0] != 0 || nor.RoundPositions[1] != 4 {
t.Errorf("NOR round positions = %v, want [0 4]", nor.RoundPositions)
}
// Teams sorted by position; Red Bull wins = VER(2) + PER(0) = 2. // Teams sorted by position; Red Bull wins = VER(2) + PER(0) = 2.
if len(resp.Teams) != 2 { if len(resp.Teams) != 2 {
t.Fatalf("teams = %d, want 2", len(resp.Teams)) t.Fatalf("teams = %d, want 2", len(resp.Teams))

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -70,6 +70,9 @@ func (s *Server) routes() (http.Handler, error) {
mux.HandleFunc("/api/v1/news", s.handleNews) mux.HandleFunc("/api/v1/news", s.handleNews)
mux.HandleFunc("/api/v1/meetings", s.handleMeetings) mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
mux.HandleFunc("/api/v1/sessions", s.handleSessions) 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/drivers", s.handleDrivers)
mux.HandleFunc("/api/v1/results", s.handleResults) mux.HandleFunc("/api/v1/results", s.handleResults)
mux.HandleFunc("/api/v1/grid", s.handleGrid) mux.HandleFunc("/api/v1/grid", s.handleGrid)