mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 12:07:23 -04:00
Compare commits
8 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82abbd6b35 | ||
|
|
09104451d0 | ||
|
|
f8bd183c26 | ||
|
|
01ec1f24e7 | ||
|
|
3f022a0cf9 | ||
|
|
5970649a9c | ||
|
|
7212eb9b44 | ||
|
|
785dccdca2 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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]
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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]
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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>
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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]
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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>
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ArticleContent,
|
||||
CarDataSample,
|
||||
ChampionshipHub,
|
||||
DriverSummary,
|
||||
EnrichedGrid,
|
||||
EnrichedResult,
|
||||
LapsComparisonResponse,
|
||||
@@ -108,6 +109,19 @@ export async function fetchChampionshipHub(year?: number): Promise<ChampionshipH
|
||||
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> {
|
||||
const res = await fetch('/api/v1/live/state')
|
||||
if (!res.ok) {
|
||||
|
||||
271
frontend/src/components/RivalryCompare.tsx
Normal file
271
frontend/src/components/RivalryCompare.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
frontend/src/lib/driverProfile.ts
Normal file
69
frontend/src/lib/driverProfile.ts
Normal 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 '='
|
||||
}
|
||||
@@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string {
|
||||
return name.slice(0, 3).toUpperCase() || '—'
|
||||
}
|
||||
|
||||
export function countryFlag(meeting: Meeting | undefined | null): string {
|
||||
export function countryFlag(
|
||||
meeting: Pick<Meeting, 'country_code' | 'country_flag'> | undefined | null,
|
||||
): string {
|
||||
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
|
||||
const code = meeting?.country_code?.toUpperCase()
|
||||
const iso2 = code ? CODE3_TO_2[code] : ''
|
||||
|
||||
61
frontend/src/lib/rivalry.ts
Normal file
61
frontend/src/lib/rivalry.ts
Normal 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)
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { fetchChampionshipHub, fetchSeasons } from '../api'
|
||||
import { teamColor } from '../utils'
|
||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||
import { RivalryCompare } from '../components/RivalryCompare'
|
||||
import { Meaning } from '../components/Meaning'
|
||||
import { pointsGapMeaning } from '../lib/meaning'
|
||||
|
||||
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
|
||||
type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
|
||||
|
||||
const GOLD = '#ffd700'
|
||||
const SILVER = '#c0c0c0'
|
||||
@@ -213,6 +215,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
>
|
||||
Progression
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`champ-tab${view === 'rivalry' ? ' is-active' : ''}`}
|
||||
onClick={() => setView('rivalry')}
|
||||
data-testid="champ-tab-rivalry"
|
||||
>
|
||||
Rivalry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
|
||||
@@ -242,10 +252,12 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
leaderPoints={leader.points}
|
||||
titleMath={titleMath}
|
||||
roundsLeft={hub.rounds_left}
|
||||
season={hub.season}
|
||||
/>
|
||||
)}
|
||||
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||||
{view === 'progression' && <ProgressionView hub={hub} />}
|
||||
{view === 'rivalry' && <RivalryCompare hub={hub} />}
|
||||
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
|
||||
</div>
|
||||
)
|
||||
@@ -271,11 +283,13 @@ function DriversView({
|
||||
leaderPoints,
|
||||
titleMath,
|
||||
roundsLeft,
|
||||
season,
|
||||
}: {
|
||||
enriched: EnrichedDriver[]
|
||||
leaderPoints: number
|
||||
titleMath: string
|
||||
roundsLeft: number
|
||||
season: number
|
||||
}) {
|
||||
const podium = enriched.slice(0, 3)
|
||||
return (
|
||||
@@ -360,12 +374,19 @@ function DriversView({
|
||||
P{e.pos}
|
||||
</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-code mono">{e.d.name_acronym}</span>
|
||||
<span className="champ-drv-name">{e.d.full_name}</span>
|
||||
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="champ-td-team">{e.d.team_name}</td>
|
||||
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
|
||||
|
||||
369
frontend/src/pages/DriverProfilePage.tsx
Normal file
369
frontend/src/pages/DriverProfilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -6,12 +6,17 @@ import { DataLibraryPage } from './pages/DataLibraryPage'
|
||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||
import { BriefingPage } from './pages/BriefingPage'
|
||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||
import { DriverProfilePage } from './pages/DriverProfilePage'
|
||||
import { RacePreviewPage } from './pages/RacePreviewPage'
|
||||
|
||||
type RaceHubSearch = {
|
||||
session_key?: number
|
||||
}
|
||||
|
||||
type DriverProfileSearch = {
|
||||
year?: number
|
||||
}
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
@@ -65,6 +70,20 @@ export const championshipRoute = createRoute({
|
||||
component: ChampionshipPage,
|
||||
})
|
||||
|
||||
export const driverProfileRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/drivers/$driverNumber',
|
||||
validateSearch: (search: Record<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({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/briefing',
|
||||
@@ -84,6 +103,7 @@ const routeTree = rootRoute.addChildren([
|
||||
dataLibraryRoute,
|
||||
liveTimingRoute,
|
||||
championshipRoute,
|
||||
driverProfileRoute,
|
||||
briefingRoute,
|
||||
previewRoute,
|
||||
])
|
||||
|
||||
289
frontend/src/styles/driver-profile.css
Normal file
289
frontend/src/styles/driver-profile.css
Normal 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);
|
||||
}
|
||||
134
frontend/src/styles/rivalry.css
Normal file
134
frontend/src/styles/rivalry.css
Normal 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;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
poles: 4,
|
||||
form: [25, 18, 25, 15, 25],
|
||||
cumulative: [25, 43, 68, 83, 108, 200],
|
||||
round_positions: [1, 2, 1, 3, 1, 1],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
@@ -47,6 +48,7 @@ const drivers: ChampHubDriver[] = [
|
||||
position: 2,
|
||||
wins: 3,
|
||||
cumulative: [18, 36, 54, 80, 120, 160],
|
||||
round_positions: [2, 1, 2, 2, 2, 2],
|
||||
}),
|
||||
driver({
|
||||
driver_number: 16,
|
||||
@@ -58,6 +60,7 @@ const drivers: ChampHubDriver[] = [
|
||||
position: 3,
|
||||
wins: 1,
|
||||
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()
|
||||
})
|
||||
|
||||
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 → 4–2.
|
||||
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('4–2')
|
||||
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to the simulator view and projects standings', async () => {
|
||||
renderPage()
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
poles: 0,
|
||||
form: [],
|
||||
cumulative: [],
|
||||
round_positions: [],
|
||||
teammate_wins: 0,
|
||||
teammate_losses: 0,
|
||||
...over,
|
||||
|
||||
@@ -156,6 +156,7 @@ describe('CommandCenterPage', () => {
|
||||
race_control: [],
|
||||
weather: [],
|
||||
laps: [],
|
||||
chapters: [],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
160
frontend/src/test/DriverProfilePage.test.tsx
Normal file
160
frontend/src/test/DriverProfilePage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -155,6 +155,7 @@ const raceHub: RaceHub = {
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
],
|
||||
chapters: [],
|
||||
}
|
||||
|
||||
const weekend: Weekend = {
|
||||
|
||||
@@ -109,6 +109,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||
poles: 4,
|
||||
form: [25],
|
||||
cumulative: [200],
|
||||
round_positions: [1],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
|
||||
114
frontend/src/test/RivalryCompare.test.tsx
Normal file
114
frontend/src/test/RivalryCompare.test.tsx
Normal 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('2–1')
|
||||
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('2–0')
|
||||
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')
|
||||
})
|
||||
})
|
||||
104
frontend/src/test/driverProfile.test.ts
Normal file
104
frontend/src/test/driverProfile.test.ts
Normal 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('—')
|
||||
})
|
||||
})
|
||||
@@ -54,6 +54,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||
poles: 4,
|
||||
form: [25],
|
||||
cumulative: [200],
|
||||
round_positions: [1],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
|
||||
71
frontend/src/test/rivalry.test.ts
Normal file
71
frontend/src/test/rivalry.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
poles: 0,
|
||||
form: [],
|
||||
cumulative: [],
|
||||
round_positions: [],
|
||||
teammate_wins: 0,
|
||||
teammate_losses: 0,
|
||||
...over,
|
||||
|
||||
@@ -91,6 +91,17 @@ export interface RaceHub {
|
||||
race_control: RaceControlMessage[]
|
||||
weather: WeatherSample[]
|
||||
laps: Lap[]
|
||||
chapters: Chapter[]
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
||||
title: string
|
||||
start_lap: number
|
||||
end_lap: number
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
driver_numbers: number[]
|
||||
}
|
||||
|
||||
export interface Stint {
|
||||
@@ -362,6 +373,7 @@ export interface ChampHubDriver {
|
||||
poles: number
|
||||
form: number[]
|
||||
cumulative: number[]
|
||||
round_positions: number[]
|
||||
teammate_wins: number
|
||||
teammate_losses: number
|
||||
}
|
||||
@@ -385,6 +397,39 @@ export interface ChampionshipHub {
|
||||
teams: ChampHubTeam[]
|
||||
}
|
||||
|
||||
export interface DriverSummaryRound {
|
||||
meeting_key: number
|
||||
meeting_name: string
|
||||
country_code: string
|
||||
country_name: string
|
||||
race_position: number
|
||||
grid_position: number
|
||||
quali_position?: number
|
||||
points: number
|
||||
dnf: boolean
|
||||
dns: boolean
|
||||
dsq: boolean
|
||||
}
|
||||
|
||||
export interface DriverSummary {
|
||||
season: number
|
||||
driver_number: number
|
||||
name_acronym: string
|
||||
full_name: string
|
||||
team_name: string
|
||||
team_colour: string
|
||||
headshot_url: string
|
||||
points: number
|
||||
position: number
|
||||
wins: number
|
||||
podiums: number
|
||||
poles: number
|
||||
form: number[]
|
||||
cumulative: number[]
|
||||
round_labels: string[]
|
||||
rounds: DriverSummaryRound[]
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
source: string
|
||||
title: string
|
||||
|
||||
628
internal/chapters/chapters.go
Normal file
628
internal/chapters/chapters.go
Normal file
@@ -0,0 +1,628 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
KindStart = "start"
|
||||
KindSafetyCar = "safety_car"
|
||||
KindVirtualSafetyCar = "virtual_safety_car"
|
||||
KindRedFlag = "red_flag"
|
||||
KindPitPhase = "pit_phase"
|
||||
KindDecisiveSwing = "decisive_swing"
|
||||
KindFinish = "finish"
|
||||
|
||||
pitPhaseWindowLaps = 3
|
||||
pitPhaseShare = 0.30
|
||||
minPitPhaseStops = 2
|
||||
maxDecisiveSwings = 3
|
||||
decisiveAfterLap = 5
|
||||
structuralPriority = 110
|
||||
flagPriority = 100
|
||||
pitPhasePriority = 50
|
||||
decisivePriority = 40
|
||||
)
|
||||
|
||||
type RaceControl = models.RaceControl
|
||||
type PositionSample = models.Position
|
||||
type Lap = models.Lap
|
||||
|
||||
// Chapter is a deterministic replay segment derived from timing and race-control data.
|
||||
type Chapter struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
StartLap int `json:"start_lap"`
|
||||
EndLap int `json:"end_lap"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
DriverNumbers []int `json:"driver_numbers"`
|
||||
}
|
||||
|
||||
// Detect builds replay chapters from already-loaded race-hub datasets.
|
||||
func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter {
|
||||
totalLaps = normalizeTotalLaps(totalLaps, laps, rc)
|
||||
if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 {
|
||||
return []Chapter{}
|
||||
}
|
||||
if totalLaps <= 0 {
|
||||
totalLaps = 1
|
||||
}
|
||||
|
||||
lapIndex := buildLapIndex(laps)
|
||||
chapters := []Chapter{
|
||||
{
|
||||
Kind: KindStart,
|
||||
Title: "Start",
|
||||
StartLap: 1,
|
||||
EndLap: minInt(1, totalLaps),
|
||||
StartTime: lapIndex.lapStart(1),
|
||||
EndTime: lapIndex.lapEnd(1),
|
||||
},
|
||||
}
|
||||
chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...)
|
||||
chapters = append(chapters, detectPitPhases(laps, lapIndex)...)
|
||||
chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...)
|
||||
chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps))
|
||||
|
||||
return resolveConflicts(chapters)
|
||||
}
|
||||
|
||||
func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int {
|
||||
for _, l := range laps {
|
||||
if l.LapNumber > totalLaps {
|
||||
totalLaps = l.LapNumber
|
||||
}
|
||||
}
|
||||
for _, msg := range rc {
|
||||
if msg.LapNumber != nil && *msg.LapNumber > totalLaps {
|
||||
totalLaps = *msg.LapNumber
|
||||
}
|
||||
}
|
||||
return totalLaps
|
||||
}
|
||||
|
||||
type lapIndex struct {
|
||||
byLap map[int]string
|
||||
events []lapEvent
|
||||
}
|
||||
|
||||
type lapEvent struct {
|
||||
lap int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func buildLapIndex(laps []Lap) lapIndex {
|
||||
idx := lapIndex{byLap: map[int]string{}}
|
||||
for _, l := range laps {
|
||||
if l.LapNumber <= 0 || l.DateStart == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := idx.byLap[l.LapNumber]; !ok {
|
||||
idx.byLap[l.LapNumber] = l.DateStart
|
||||
}
|
||||
at, ok := parseTime(l.DateStart)
|
||||
if ok {
|
||||
idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at})
|
||||
}
|
||||
}
|
||||
sort.Slice(idx.events, func(i, j int) bool {
|
||||
if idx.events[i].at.Equal(idx.events[j].at) {
|
||||
return idx.events[i].lap < idx.events[j].lap
|
||||
}
|
||||
return idx.events[i].at.Before(idx.events[j].at)
|
||||
})
|
||||
return idx
|
||||
}
|
||||
|
||||
func (idx lapIndex) lapStart(lap int) string {
|
||||
return idx.byLap[lap]
|
||||
}
|
||||
|
||||
func (idx lapIndex) lapEnd(lap int) string {
|
||||
if v := idx.byLap[lap+1]; v != "" {
|
||||
return v
|
||||
}
|
||||
return idx.byLap[lap]
|
||||
}
|
||||
|
||||
func (idx lapIndex) lapForTime(raw string) int {
|
||||
at, ok := parseTime(raw)
|
||||
if !ok || len(idx.events) == 0 {
|
||||
return 0
|
||||
}
|
||||
lap := 0
|
||||
for _, event := range idx.events {
|
||||
if event.at.After(at) {
|
||||
break
|
||||
}
|
||||
lap = event.lap
|
||||
}
|
||||
if lap == 0 {
|
||||
return idx.events[0].lap
|
||||
}
|
||||
return lap
|
||||
}
|
||||
|
||||
type flagState struct {
|
||||
startLap int
|
||||
startTime string
|
||||
}
|
||||
|
||||
func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter {
|
||||
var chapters []Chapter
|
||||
active := map[string]flagState{}
|
||||
for _, msg := range rc {
|
||||
kind, ok := flagKind(msg)
|
||||
if !ok && greenFlagClear(msg) {
|
||||
for activeKind, st := range active {
|
||||
lap := messageLap(msg, idx)
|
||||
if lap <= 0 {
|
||||
lap = st.startLap
|
||||
}
|
||||
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||
chapters = append(chapters, Chapter{
|
||||
Kind: activeKind,
|
||||
Title: flagTitle(activeKind, st.startLap, endLap),
|
||||
StartLap: st.startLap,
|
||||
EndLap: endLap,
|
||||
StartTime: st.startTime,
|
||||
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||
})
|
||||
delete(active, activeKind)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lap := messageLap(msg, idx)
|
||||
if lap <= 0 {
|
||||
lap = 1
|
||||
}
|
||||
if flagCleared(msg) {
|
||||
st, ok := active[kind]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||
chapters = append(chapters, Chapter{
|
||||
Kind: kind,
|
||||
Title: flagTitle(kind, st.startLap, endLap),
|
||||
StartLap: st.startLap,
|
||||
EndLap: endLap,
|
||||
StartTime: st.startTime,
|
||||
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||
})
|
||||
delete(active, kind)
|
||||
continue
|
||||
}
|
||||
if flagStarted(msg) {
|
||||
active[kind] = flagState{
|
||||
startLap: clampLap(lap, 1, totalLaps),
|
||||
startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)),
|
||||
}
|
||||
}
|
||||
}
|
||||
for kind, st := range active {
|
||||
endLap := totalLaps
|
||||
chapters = append(chapters, Chapter{
|
||||
Kind: kind,
|
||||
Title: flagTitle(kind, st.startLap, endLap),
|
||||
StartLap: st.startLap,
|
||||
EndLap: endLap,
|
||||
StartTime: st.startTime,
|
||||
EndTime: idx.lapEnd(endLap),
|
||||
})
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
func flagKind(msg RaceControl) (string, bool) {
|
||||
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||
if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") {
|
||||
return KindVirtualSafetyCar, true
|
||||
}
|
||||
if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) {
|
||||
return KindRedFlag, true
|
||||
}
|
||||
if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar {
|
||||
return KindSafetyCar, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func flagStarted(msg RaceControl) bool {
|
||||
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||
if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(text, "DEPLOY") ||
|
||||
strings.Contains(text, "RED FLAG") ||
|
||||
strings.Contains(text, "VIRTUAL SAFETY CAR") ||
|
||||
strings.Contains(text, "VSC") ||
|
||||
strings.Contains(text, "SAFETY CAR") ||
|
||||
string(msg.Flag) == string(models.FlagRed)
|
||||
}
|
||||
|
||||
func flagCleared(msg RaceControl) bool {
|
||||
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||
return strings.Contains(text, "CLEAR") ||
|
||||
strings.Contains(text, "ENDING") ||
|
||||
strings.Contains(text, "IN THIS LAP") ||
|
||||
strings.Contains(text, "GREEN")
|
||||
}
|
||||
|
||||
func greenFlagClear(msg RaceControl) bool {
|
||||
text := upperText(string(msg.Flag), msg.Message)
|
||||
return strings.Contains(text, "GREEN")
|
||||
}
|
||||
|
||||
func flagTitle(kind string, startLap, endLap int) string {
|
||||
name := "Flag period"
|
||||
switch kind {
|
||||
case KindSafetyCar:
|
||||
name = "Safety Car"
|
||||
case KindVirtualSafetyCar:
|
||||
name = "Virtual Safety Car"
|
||||
case KindRedFlag:
|
||||
name = "Red Flag"
|
||||
}
|
||||
return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap)
|
||||
}
|
||||
|
||||
func detectPitPhases(laps []Lap, idx lapIndex) []Chapter {
|
||||
type pitOut struct {
|
||||
lap int
|
||||
driver int
|
||||
}
|
||||
var stops []pitOut
|
||||
for _, l := range laps {
|
||||
if l.IsPitOutLap && l.LapNumber > 0 {
|
||||
stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber})
|
||||
}
|
||||
}
|
||||
if len(stops) < minPitPhaseStops {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(stops, func(i, j int) bool {
|
||||
if stops[i].lap == stops[j].lap {
|
||||
return stops[i].driver < stops[j].driver
|
||||
}
|
||||
return stops[i].lap < stops[j].lap
|
||||
})
|
||||
needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare))
|
||||
if needed < minPitPhaseStops {
|
||||
needed = minPitPhaseStops
|
||||
}
|
||||
|
||||
var windows []Chapter
|
||||
for i := 0; i < len(stops); i++ {
|
||||
start := stops[i].lap
|
||||
end := start + pitPhaseWindowLaps - 1
|
||||
drivers := map[int]bool{}
|
||||
count := 0
|
||||
for _, stop := range stops {
|
||||
if stop.lap < start || stop.lap > end {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
drivers[stop.driver] = true
|
||||
}
|
||||
if count < needed {
|
||||
continue
|
||||
}
|
||||
ch := Chapter{
|
||||
Kind: KindPitPhase,
|
||||
Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end),
|
||||
StartLap: start,
|
||||
EndLap: end,
|
||||
StartTime: idx.lapStart(start),
|
||||
EndTime: idx.lapEnd(end),
|
||||
DriverNumbers: sortedDriverNumbers(drivers),
|
||||
}
|
||||
if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 {
|
||||
last := &windows[len(windows)-1]
|
||||
if ch.EndLap > last.EndLap {
|
||||
last.EndLap = ch.EndLap
|
||||
last.EndTime = idx.lapEnd(last.EndLap)
|
||||
}
|
||||
drivers := sliceToSet(last.DriverNumbers)
|
||||
for _, driver := range ch.DriverNumbers {
|
||||
drivers[driver] = true
|
||||
}
|
||||
last.DriverNumbers = sortedDriverNumbers(drivers)
|
||||
last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap)
|
||||
continue
|
||||
}
|
||||
windows = append(windows, ch)
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
type swingCandidate struct {
|
||||
chapter Chapter
|
||||
significance int
|
||||
}
|
||||
|
||||
func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter {
|
||||
if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap {
|
||||
return nil
|
||||
}
|
||||
snapshots := buildPositionSnapshots(positions, idx, totalLaps)
|
||||
if len(snapshots) == 0 {
|
||||
return nil
|
||||
}
|
||||
final := snapshots[totalLaps]
|
||||
if len(final) == 0 {
|
||||
for lap := totalLaps - 1; lap >= 1; lap-- {
|
||||
if len(snapshots[lap]) > 0 {
|
||||
final = snapshots[lap]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var candidates []swingCandidate
|
||||
seenDriver := map[int]bool{}
|
||||
for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ {
|
||||
prev := snapshots[lap-1]
|
||||
curr := snapshots[lap]
|
||||
if len(prev) == 0 || len(curr) == 0 {
|
||||
continue
|
||||
}
|
||||
for driver, pos := range curr {
|
||||
prevPos, ok := prev[driver]
|
||||
if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] {
|
||||
continue
|
||||
}
|
||||
finalPos, ok := final[driver]
|
||||
if !ok || finalPos > pos {
|
||||
continue
|
||||
}
|
||||
overtaken := driverAtPosition(curr, prevPos, driver)
|
||||
drivers := []int{driver}
|
||||
if overtaken != 0 {
|
||||
drivers = append(drivers, overtaken)
|
||||
}
|
||||
candidates = append(candidates, swingCandidate{
|
||||
chapter: Chapter{
|
||||
Kind: KindDecisiveSwing,
|
||||
Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap),
|
||||
StartLap: lap,
|
||||
EndLap: lap,
|
||||
StartTime: idx.lapStart(lap),
|
||||
EndTime: idx.lapEnd(lap),
|
||||
DriverNumbers: drivers,
|
||||
},
|
||||
significance: (prevPos-pos)*10 + (6 - pos),
|
||||
})
|
||||
seenDriver[driver] = true
|
||||
}
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].significance == candidates[j].significance {
|
||||
return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap
|
||||
}
|
||||
return candidates[i].significance > candidates[j].significance
|
||||
})
|
||||
if len(candidates) > maxDecisiveSwings {
|
||||
candidates = candidates[:maxDecisiveSwings]
|
||||
}
|
||||
out := make([]Chapter, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
out = append(out, c.chapter)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int {
|
||||
byLap := map[int][]PositionSample{}
|
||||
for _, p := range positions {
|
||||
if p.Position <= 0 {
|
||||
continue
|
||||
}
|
||||
lap := idx.lapForTime(p.Date)
|
||||
if lap <= 0 || lap > totalLaps {
|
||||
continue
|
||||
}
|
||||
byLap[lap] = append(byLap[lap], p)
|
||||
}
|
||||
last := map[int]int{}
|
||||
snapshots := map[int]map[int]int{}
|
||||
for lap := 1; lap <= totalLaps; lap++ {
|
||||
for _, p := range byLap[lap] {
|
||||
last[p.DriverNumber] = p.Position
|
||||
}
|
||||
if len(last) == 0 {
|
||||
continue
|
||||
}
|
||||
cp := make(map[int]int, len(last))
|
||||
for driver, pos := range last {
|
||||
cp[driver] = pos
|
||||
}
|
||||
snapshots[lap] = cp
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func driverAtPosition(snapshot map[int]int, pos int, exclude int) int {
|
||||
for driver, driverPos := range snapshot {
|
||||
if driver != exclude && driverPos == pos {
|
||||
return driver
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter {
|
||||
finishLap := totalLaps
|
||||
finishTime := idx.lapEnd(totalLaps)
|
||||
for _, msg := range rc {
|
||||
text := upperText(string(msg.Flag), msg.Message)
|
||||
if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) {
|
||||
if lap := messageLap(msg, idx); lap > 0 {
|
||||
finishLap = lap
|
||||
}
|
||||
finishTime = firstNonEmpty(msg.Date, finishTime)
|
||||
}
|
||||
}
|
||||
startLap := finishLap - 1
|
||||
if startLap < 1 {
|
||||
startLap = 1
|
||||
}
|
||||
return Chapter{
|
||||
Kind: KindFinish,
|
||||
Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap),
|
||||
StartLap: startLap,
|
||||
EndLap: finishLap,
|
||||
StartTime: idx.lapStart(startLap),
|
||||
EndTime: finishTime,
|
||||
}
|
||||
}
|
||||
|
||||
func resolveConflicts(chapters []Chapter) []Chapter {
|
||||
normalized := make([]Chapter, 0, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
if ch.StartLap <= 0 {
|
||||
ch.StartLap = 1
|
||||
}
|
||||
if ch.EndLap <= 0 {
|
||||
ch.EndLap = ch.StartLap
|
||||
}
|
||||
if ch.EndLap < ch.StartLap {
|
||||
ch.EndLap = ch.StartLap
|
||||
}
|
||||
if ch.DriverNumbers == nil {
|
||||
ch.DriverNumbers = []int{}
|
||||
}
|
||||
normalized = append(normalized, ch)
|
||||
}
|
||||
sort.SliceStable(normalized, func(i, j int) bool {
|
||||
if normalized[i].StartLap == normalized[j].StartLap {
|
||||
return priority(normalized[i].Kind) > priority(normalized[j].Kind)
|
||||
}
|
||||
return normalized[i].StartLap < normalized[j].StartLap
|
||||
})
|
||||
|
||||
out := make([]Chapter, 0, len(normalized))
|
||||
for _, ch := range normalized {
|
||||
if len(out) == 0 {
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
last := &out[len(out)-1]
|
||||
if ch.StartLap > last.EndLap {
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) {
|
||||
continue
|
||||
}
|
||||
if priority(ch.Kind) > priority(last.Kind) {
|
||||
if last.StartLap < ch.StartLap {
|
||||
last.EndLap = ch.StartLap - 1
|
||||
out = append(out, ch)
|
||||
} else {
|
||||
*last = ch
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch.EndLap > last.EndLap {
|
||||
ch.StartLap = last.EndLap + 1
|
||||
if ch.StartLap <= ch.EndLap {
|
||||
out = append(out, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isFlag(kind string) bool {
|
||||
return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag
|
||||
}
|
||||
|
||||
func priority(kind string) int {
|
||||
switch kind {
|
||||
case KindStart, KindFinish:
|
||||
return structuralPriority
|
||||
case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag:
|
||||
return flagPriority
|
||||
case KindPitPhase:
|
||||
return pitPhasePriority
|
||||
case KindDecisiveSwing:
|
||||
return decisivePriority
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func messageLap(msg RaceControl, idx lapIndex) int {
|
||||
if msg.LapNumber != nil && *msg.LapNumber > 0 {
|
||||
return *msg.LapNumber
|
||||
}
|
||||
return idx.lapForTime(msg.Date)
|
||||
}
|
||||
|
||||
func clampLap(lap, minLap, maxLap int) int {
|
||||
if lap < minLap {
|
||||
return minLap
|
||||
}
|
||||
if maxLap > 0 && lap > maxLap {
|
||||
return maxLap
|
||||
}
|
||||
return lap
|
||||
}
|
||||
|
||||
func parseTime(raw string) (time.Time, bool) {
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
at, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return at, true
|
||||
}
|
||||
|
||||
func upperText(parts ...string) string {
|
||||
return strings.ToUpper(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortedDriverNumbers(drivers map[int]bool) []int {
|
||||
out := make([]int, 0, len(drivers))
|
||||
for driver := range drivers {
|
||||
out = append(out, driver)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sliceToSet(values []int) map[int]bool {
|
||||
out := make(map[int]bool, len(values))
|
||||
for _, value := range values {
|
||||
out[value] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
218
internal/chapters/chapters_test.go
Normal file
218
internal/chapters/chapters_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
func TestDetectStructuralChapters(t *testing.T) {
|
||||
chapters := Detect(nil, nil, testLaps(10, nil), 10)
|
||||
|
||||
if len(chapters) < 2 {
|
||||
t.Fatalf("chapters len = %d, want at least start and finish", len(chapters))
|
||||
}
|
||||
if got := chapters[0]; got.Kind != KindStart || got.StartLap != 1 || got.EndLap != 1 {
|
||||
t.Fatalf("start chapter = %+v, want L1-L1", got)
|
||||
}
|
||||
got := chapters[len(chapters)-1]
|
||||
if got.Kind != KindFinish || got.StartLap != 9 || got.EndLap != 10 {
|
||||
t.Fatalf("finish chapter = %+v, want L9-L10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectReturnsEmptyWithoutData(t *testing.T) {
|
||||
if chapters := Detect(nil, nil, nil, 0); len(chapters) != 0 {
|
||||
t.Fatalf("chapters = %+v, want empty", chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFlagPeriods(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
start RaceControl
|
||||
end RaceControl
|
||||
wantKind string
|
||||
wantTitle string
|
||||
}{
|
||||
{
|
||||
name: "safety car",
|
||||
start: rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||
end: rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||
wantKind: KindSafetyCar,
|
||||
wantTitle: "Safety Car (L12-L15)",
|
||||
},
|
||||
{
|
||||
name: "virtual safety car",
|
||||
start: rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
|
||||
end: rc(24, models.CategoryOther, "", "VSC ENDING"),
|
||||
wantKind: KindVirtualSafetyCar,
|
||||
wantTitle: "Virtual Safety Car (L22-L24)",
|
||||
},
|
||||
{
|
||||
name: "red flag",
|
||||
start: rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
|
||||
end: rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
|
||||
wantKind: KindRedFlag,
|
||||
wantTitle: "Red Flag (L31-L33)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
chapters := Detect([]RaceControl{tt.start, tt.end}, nil, testLaps(40, nil), 40)
|
||||
got := findKind(chapters, tt.wantKind)
|
||||
if got == nil {
|
||||
t.Fatalf("chapters = %+v, want %s", chapters, tt.wantKind)
|
||||
}
|
||||
if got.StartLap != rcLap(tt.start) || got.EndLap != rcLap(tt.end) || got.Title != tt.wantTitle {
|
||||
t.Fatalf("flag chapter = %+v, want %s", *got, tt.wantTitle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectPitPhaseFromPitOutLapCluster(t *testing.T) {
|
||||
pitOuts := map[int][]int{
|
||||
5: {1},
|
||||
10: {2},
|
||||
20: {3},
|
||||
21: {4},
|
||||
22: {5},
|
||||
30: {6},
|
||||
35: {7},
|
||||
40: {8},
|
||||
45: {9},
|
||||
50: {10},
|
||||
}
|
||||
chapters := Detect(nil, nil, testLaps(55, pitOuts), 55)
|
||||
|
||||
got := findKind(chapters, KindPitPhase)
|
||||
if got == nil {
|
||||
t.Fatalf("chapters = %+v, want pit phase", chapters)
|
||||
}
|
||||
if got.StartLap != 20 || got.EndLap != 22 {
|
||||
t.Fatalf("pit phase = %+v, want L20-L22", *got)
|
||||
}
|
||||
if len(got.DriverNumbers) != 3 || got.DriverNumbers[0] != 3 || got.DriverNumbers[2] != 5 {
|
||||
t.Fatalf("pit phase drivers = %v, want [3 4 5]", got.DriverNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectDecisiveSwingPersistsToFinish(t *testing.T) {
|
||||
positions := []PositionSample{
|
||||
pos(1, 1, 1),
|
||||
pos(1, 16, 4),
|
||||
pos(1, 55, 3),
|
||||
pos(6, 16, 3),
|
||||
pos(6, 55, 4),
|
||||
pos(8, 44, 5),
|
||||
pos(8, 63, 6),
|
||||
pos(10, 44, 6),
|
||||
}
|
||||
|
||||
chapters := Detect(nil, positions, testLaps(12, nil), 12)
|
||||
got := findKind(chapters, KindDecisiveSwing)
|
||||
if got == nil {
|
||||
t.Fatalf("chapters = %+v, want decisive swing", chapters)
|
||||
}
|
||||
if got.StartLap != 6 || got.EndLap != 6 {
|
||||
t.Fatalf("swing lap = %+v, want L6", *got)
|
||||
}
|
||||
if len(got.DriverNumbers) != 2 || got.DriverNumbers[0] != 16 || got.DriverNumbers[1] != 55 {
|
||||
t.Fatalf("swing drivers = %v, want [16 55]", got.DriverNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFlagPeriodsWinOverConflictingChapters(t *testing.T) {
|
||||
pitOuts := map[int][]int{
|
||||
12: {1, 2},
|
||||
13: {3, 4},
|
||||
14: {5, 6},
|
||||
}
|
||||
rcs := []RaceControl{
|
||||
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||
}
|
||||
|
||||
chapters := Detect(rcs, nil, testLaps(20, pitOuts), 20)
|
||||
if got := findKind(chapters, KindSafetyCar); got == nil || got.StartLap != 12 || got.EndLap != 15 {
|
||||
t.Fatalf("chapters = %+v, want safety car L12-L15", chapters)
|
||||
}
|
||||
if got := findKind(chapters, KindPitPhase); got != nil {
|
||||
t.Fatalf("pit phase = %+v, want omitted under safety car", *got)
|
||||
}
|
||||
for i := 1; i < len(chapters); i++ {
|
||||
if chapters[i].StartLap <= chapters[i-1].EndLap {
|
||||
t.Fatalf("chapters overlap at %d: %+v then %+v", i, chapters[i-1], chapters[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rcLap(r RaceControl) int {
|
||||
if r.LapNumber == nil {
|
||||
return 0
|
||||
}
|
||||
return *r.LapNumber
|
||||
}
|
||||
|
||||
func findKind(chapters []Chapter, kind string) *Chapter {
|
||||
for i := range chapters {
|
||||
if chapters[i].Kind == kind {
|
||||
return &chapters[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testLaps(total int, pitOuts map[int][]int) []Lap {
|
||||
var laps []Lap
|
||||
for lap := 1; lap <= total; lap++ {
|
||||
drivers := []int{1}
|
||||
if pitDrivers := pitOuts[lap]; len(pitDrivers) > 0 {
|
||||
drivers = pitDrivers
|
||||
}
|
||||
for _, driver := range drivers {
|
||||
laps = append(laps, Lap{
|
||||
DriverNumber: driver,
|
||||
LapNumber: lap,
|
||||
DateStart: lapTime(lap),
|
||||
IsPitOutLap: containsDriver(pitOuts[lap], driver),
|
||||
})
|
||||
}
|
||||
}
|
||||
return laps
|
||||
}
|
||||
|
||||
func rc(lap int, category models.RaceControlCategory, flag models.Flag, message string) RaceControl {
|
||||
return RaceControl{
|
||||
Category: category,
|
||||
Flag: flag,
|
||||
Message: message,
|
||||
LapNumber: &lap,
|
||||
Date: lapTime(lap),
|
||||
}
|
||||
}
|
||||
|
||||
func pos(lap int, driver int, position int) PositionSample {
|
||||
return PositionSample{
|
||||
DriverNumber: driver,
|
||||
Position: position,
|
||||
Date: lapTime(lap),
|
||||
}
|
||||
}
|
||||
|
||||
func lapTime(lap int) string {
|
||||
minute := lap - 1
|
||||
return fmt.Sprintf("2025-05-25T13:%02d:00Z", minute)
|
||||
}
|
||||
|
||||
func containsDriver(drivers []int, driver int) bool {
|
||||
for _, candidate := range drivers {
|
||||
if candidate == driver {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
@@ -52,6 +53,7 @@ type RaceHub struct {
|
||||
RaceControl []models.RaceControl `json:"race_control"`
|
||||
Weather []models.Weather `json:"weather"`
|
||||
Laps []models.Lap `json:"laps"`
|
||||
Chapters []chapters.Chapter `json:"chapters"`
|
||||
}
|
||||
|
||||
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
||||
@@ -80,6 +82,7 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
RaceControl: []models.RaceControl{},
|
||||
Weather: []models.Weather{},
|
||||
Laps: []models.Lap{},
|
||||
Chapters: []chapters.Chapter{},
|
||||
}
|
||||
|
||||
sess, err := s.store.GetSession(sessionKey)
|
||||
@@ -248,6 +251,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
|
||||
}
|
||||
|
||||
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
||||
hub.Source = responseSource(hub.Datasets)
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
||||
total := 0
|
||||
for _, result := range results {
|
||||
if result.NumberOfLaps > total {
|
||||
total = result.NumberOfLaps
|
||||
}
|
||||
}
|
||||
for _, lap := range laps {
|
||||
if lap.LapNumber > total {
|
||||
total = lap.LapNumber
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -616,6 +616,7 @@ type champHubDriver struct {
|
||||
Poles int `json:"poles"`
|
||||
Form []float64 `json:"form"` // last 5 races' points
|
||||
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"`
|
||||
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) {
|
||||
meetings, err := s.client.GetMeetingsForYear(year)
|
||||
if err != nil {
|
||||
return champHubResponse{}, err
|
||||
}
|
||||
|
||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
||||
if err != nil {
|
||||
return champHubResponse{}, err
|
||||
@@ -816,16 +812,39 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
||||
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
|
||||
// aggregate: serve it so the page still renders, but cache it only briefly
|
||||
// so a partial view of the season doesn't stick around for the full TTL.
|
||||
var incomplete atomic.Bool
|
||||
races := fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
|
||||
ttl = champHubIncompleteTTL
|
||||
}
|
||||
s.hubCache.put(year, resp, time.Now(), ttl)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled
|
||||
// with its race results and starting grid fetched from OpenF1. incomplete
|
||||
// reports whether any per-meeting fetch failed, so callers can avoid caching a
|
||||
// partial view of the season for long.
|
||||
func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) {
|
||||
meetings, err := s.client.GetMeetingsForYear(year)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart })
|
||||
|
||||
var failed atomic.Bool
|
||||
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
|
||||
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
|
||||
if serr != nil {
|
||||
incomplete.Store(true)
|
||||
failed.Store(true)
|
||||
return meetingRace{}, false
|
||||
}
|
||||
raceKey := 0
|
||||
@@ -841,18 +860,11 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
||||
results, rerr := s.client.GetSessionResult(raceKey)
|
||||
grid, gerr := s.client.GetStartingGrid(raceKey)
|
||||
if rerr != nil || gerr != nil {
|
||||
incomplete.Store(true)
|
||||
failed.Store(true)
|
||||
}
|
||||
return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true
|
||||
})
|
||||
|
||||
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
|
||||
ttl := champHubTTL(year, time.Now())
|
||||
if incomplete.Load() {
|
||||
ttl = champHubIncompleteTTL
|
||||
}
|
||||
s.hubCache.put(year, resp, time.Now(), ttl)
|
||||
return resp, nil
|
||||
return races, failed.Load(), nil
|
||||
}
|
||||
|
||||
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
|
||||
@@ -984,6 +996,10 @@ func aggregateChampionshipHub(
|
||||
if 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]
|
||||
drivers = append(drivers, champHubDriver{
|
||||
DriverNumber: c.DriverNumber,
|
||||
@@ -998,6 +1014,7 @@ func aggregateChampionshipHub(
|
||||
Poles: a.poles,
|
||||
Form: form,
|
||||
Cumulative: cumulative[c.DriverNumber],
|
||||
RoundPositions: roundPositions,
|
||||
TeammateWins: twins[c.DriverNumber],
|
||||
TeammateLosses: tloss[c.DriverNumber],
|
||||
})
|
||||
|
||||
@@ -17,20 +17,22 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
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"},
|
||||
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{
|
||||
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
|
||||
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
|
||||
{DriverNumber: 2, PointsCurrent: 30, PositionCurrent: 3, SessionKey: 99},
|
||||
{DriverNumber: 4, PointsCurrent: 12, PositionCurrent: 4, SessionKey: 99},
|
||||
}
|
||||
teams := []models.ChampionshipTeam{
|
||||
{TeamName: "Red Bull", PointsCurrent: 80, PositionCurrent: 1},
|
||||
{TeamName: "Mercedes", PointsCurrent: 33, PositionCurrent: 2},
|
||||
}
|
||||
|
||||
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER.
|
||||
// Round 2: VER P1(25), PER P2(18), HAM P3(15). Pole: HAM.
|
||||
// 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), NOR P4(12). Pole: HAM.
|
||||
races := []meetingRace{
|
||||
{
|
||||
Meeting: models.Meeting{MeetingName: "Bahrain GP"},
|
||||
@@ -39,7 +41,7 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
},
|
||||
{
|
||||
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}},
|
||||
},
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Drivers are sorted by official position: VER, HAM, PER.
|
||||
if len(resp.Drivers) != 3 {
|
||||
t.Fatalf("drivers = %d, want 3", len(resp.Drivers))
|
||||
// Drivers are sorted by official position: VER, HAM, PER, NOR.
|
||||
if len(resp.Drivers) != 4 {
|
||||
t.Fatalf("drivers = %d, want 4", len(resp.Drivers))
|
||||
}
|
||||
ver := resp.Drivers[0]
|
||||
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 {
|
||||
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.
|
||||
if ver.TeammateWins != 2 || ver.TeammateLosses != 0 {
|
||||
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 {
|
||||
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.
|
||||
var ham champHubDriver
|
||||
@@ -124,6 +132,17 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
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.
|
||||
if len(resp.Teams) != 2 {
|
||||
t.Fatalf("teams = %d, want 2", len(resp.Teams))
|
||||
|
||||
177
internal/web/driversummary.go
Normal file
177
internal/web/driversummary.go
Normal 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
|
||||
}
|
||||
136
internal/web/driversummary_test.go
Normal file
136
internal/web/driversummary_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/query"
|
||||
)
|
||||
@@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub {
|
||||
Drivers: []models.Driver{},
|
||||
Results: []query.EnrichedResult{},
|
||||
StartingGrid: []query.EnrichedGrid{},
|
||||
Chapters: []chapters.Chapter{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,66 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRaceHubIncludesChapters(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
seedRaceHubStore(t, st)
|
||||
sessionKey := 9472
|
||||
meetingKey := 1229
|
||||
|
||||
for lap := 1; lap <= 12; lap++ {
|
||||
if err := st.UpsertLap(store.Lap{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
LapNumber: lap,
|
||||
DateStart: time.Date(2025, 5, 25, 13, lap-1, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
LapDuration: 75,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertLap(%d) error = %v", lap, err)
|
||||
}
|
||||
}
|
||||
for _, sample := range []store.PositionSample{
|
||||
{SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 1},
|
||||
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 4},
|
||||
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 3},
|
||||
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 3},
|
||||
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 4},
|
||||
} {
|
||||
if err := st.UpsertPositionSample(sample); err != nil {
|
||||
t.Fatalf("UpsertPositionSample() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
srv := testServer(t, st)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRaceHub(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
var hub query.RaceHub
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(hub.Chapters) == 0 {
|
||||
t.Fatalf("chapters = %+v, want generated chapters", hub.Chapters)
|
||||
}
|
||||
if hub.Chapters[0].Kind != "start" {
|
||||
t.Fatalf("first chapter = %+v, want start", hub.Chapters[0])
|
||||
}
|
||||
foundSwing := false
|
||||
for _, chapter := range hub.Chapters {
|
||||
if chapter.Kind == "decisive_swing" {
|
||||
foundSwing = true
|
||||
}
|
||||
}
|
||||
if !foundSwing {
|
||||
t.Fatalf("chapters = %+v, want decisive_swing", hub.Chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMeetingsSourceLocal(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
seedRaceHubStore(t, st)
|
||||
|
||||
@@ -70,6 +70,9 @@ func (s *Server) routes() (http.Handler, error) {
|
||||
mux.HandleFunc("/api/v1/news", s.handleNews)
|
||||
mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
|
||||
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
|
||||
// /api/v1/driver/summary before /api/v1/drivers: distinct prefixes today,
|
||||
// but keep the more specific driver/* paths registered first.
|
||||
mux.HandleFunc("/api/v1/driver/summary", s.handleDriverSummary)
|
||||
mux.HandleFunc("/api/v1/drivers", s.handleDrivers)
|
||||
mux.HandleFunc("/api/v1/results", s.handleResults)
|
||||
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
||||
|
||||
Reference in New Issue
Block a user