From 82abbd6b35617aa049b0f17315c3919a9a0c6145 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Sat, 11 Jul 2026 18:29:52 -0400 Subject: [PATCH] feat(#24): Rivalry compare view Implemented by claude via .agents/dev dispatch. --- frontend/src/components/RivalryCompare.tsx | 271 ++++++++++++++++++ frontend/src/lib/rivalry.ts | 61 ++++ frontend/src/pages/ChampionshipPage.tsx | 12 +- frontend/src/styles/rivalry.css | 134 +++++++++ frontend/src/test/ChampionshipPage.test.tsx | 19 ++ .../src/test/ChampionshipSimulator.test.tsx | 1 + frontend/src/test/RacePreviewPage.test.tsx | 1 + frontend/src/test/RivalryCompare.test.tsx | 114 ++++++++ frontend/src/test/preview.test.ts | 1 + frontend/src/test/rivalry.test.ts | 71 +++++ frontend/src/test/simulator.test.ts | 1 + frontend/src/types.ts | 1 + internal/web/api.go | 10 +- internal/web/championship_hub_test.go | 31 +- 14 files changed, 719 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/RivalryCompare.tsx create mode 100644 frontend/src/lib/rivalry.ts create mode 100644 frontend/src/styles/rivalry.css create mode 100644 frontend/src/test/RivalryCompare.test.tsx create mode 100644 frontend/src/test/rivalry.test.ts diff --git a/frontend/src/components/RivalryCompare.tsx b/frontend/src/components/RivalryCompare.tsx new file mode 100644 index 0000000..2d89d80 --- /dev/null +++ b/frontend/src/components/RivalryCompare.tsx @@ -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 ( + + ) +} + +export function RivalryCompare({ hub }: { hub: ChampionshipHub }) { + const { drivers } = hub + const [pickA, setPickA] = useState(null) + const [pickB, setPickB] = useState(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 ( +
+ Need at least two drivers in the standings to compare a rivalry. +
+ ) + } + + 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 = ( +
+ + vs + +
+ ) + + if (rounds === 0) { + return ( +
+ {pickers} +
+ No completed rounds yet — the rivalry will appear after the first race. +
+
+ ) + } + + // 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 ( +
+ {pickers} + +
+
+ + {a.name_acronym} + + + {tally ? `${tally.a}–${tally.b}` : '—'} + + + {b.name_acronym} + +
+ + Race head-to-head · {tally?.rounds.length ?? 0} round{(tally?.rounds.length ?? 0) === 1 ? '' : 's'} counted + {tally && tally.skipped > 0 ? ` · ${tally.skipped} skipped` : ''} + + {strip.length > 0 && ( +
+ Last {strip.length} + {strip.map((r) => ( + + {r.label} + + {r.winner === 'a' ? a.name_acronym : b.name_acronym} + + + ))} +
+ )} +
+ +
+
+ + Points race — {a.name_acronym} vs {b.name_acronym} + + + Rounds 1–{rounds} · {hub.season} + +
+
+ + {pyGrid.map((g) => ( + + + + {g.label} + + + ))} + {pxGrid.map((g, i) => ( + + {g.label} + + ))} + {[ + { 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) => ( + + + + + {s.d.name_acronym} + + + ))} + +
+
+ +
+
+ + Gap over season — {a.name_acronym} − {b.name_acronym} + + {gapCaption} +
+
+ + {gyGrid.map((g) => ( + + + + {g.label} + + + ))} + {gxGrid.map((g, i) => ( + + {g.label} + + ))} + + + +
+

+ Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead. +

+
+
+ ) +} diff --git a/frontend/src/lib/rivalry.ts b/frontend/src/lib/rivalry.ts new file mode 100644 index 0000000..f5b4092 --- /dev/null +++ b/frontend/src/lib/rivalry.ts @@ -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) +} diff --git a/frontend/src/pages/ChampionshipPage.tsx b/frontend/src/pages/ChampionshipPage.tsx index b922202..f26253b 100644 --- a/frontend/src/pages/ChampionshipPage.tsx +++ b/frontend/src/pages/ChampionshipPage.tsx @@ -5,10 +5,11 @@ 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' @@ -214,6 +215,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) { > Progression +