From 2220d5d30a0f7a79ab4d295c91218bc9aa51b424 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 3 Jul 2026 00:09:47 -0400 Subject: [PATCH] Add championship hub with derived stats and progression views New /api/v1/championship/hub endpoint aggregates official standings with wins, podiums, poles, recent form, teammate head-to-head, and per-round cumulative points. ChampionshipPage renders drivers, constructors, and progression views. Co-Authored-By: Claude Fable 5 --- frontend/src/api.ts | 20 +- frontend/src/components/Nav.tsx | 3 + frontend/src/pages/ChampionshipPage.tsx | 618 ++++++++++++++++++++ frontend/src/router.tsx | 8 + frontend/src/styles/app.css | 510 ++++++++++++++++ frontend/src/test/ChampionshipPage.test.tsx | 139 +++++ frontend/src/types.ts | 36 ++ internal/web/api.go | 286 +++++++++ internal/web/championship_hub_test.go | 142 +++++ internal/web/server.go | 1 + 10 files changed, 1762 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/ChampionshipPage.tsx create mode 100644 frontend/src/test/ChampionshipPage.test.tsx create mode 100644 internal/web/championship_hub_test.go diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d348b79..0975cfe 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,13 @@ -import type { ArticleContent, LiveStateResponse, Meeting, NewsItem, RaceHub, Session, Weekend } from './types' +import type { + ArticleContent, + ChampionshipHub, + LiveStateResponse, + Meeting, + NewsItem, + RaceHub, + Session, + Weekend, +} from './types' export async function fetchRaceHub(sessionKey: number): Promise { const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`) @@ -52,6 +61,15 @@ export async function fetchWeekend(meetingKey: number): Promise { return res.json() } +export async function fetchChampionshipHub(year?: number): Promise { + const url = year ? `/api/v1/championship/hub?year=${year}` : '/api/v1/championship/hub' + const res = await fetch(url) + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`) + } + return res.json() +} + export async function fetchLiveState(): Promise { const res = await fetch('/api/v1/live/state') if (!res.ok) { diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx index 202f65e..f337c1c 100644 --- a/frontend/src/components/Nav.tsx +++ b/frontend/src/components/Nav.tsx @@ -16,6 +16,9 @@ export function Nav() { Race Hub + + Championship + Briefing diff --git a/frontend/src/pages/ChampionshipPage.tsx b/frontend/src/pages/ChampionshipPage.tsx new file mode 100644 index 0000000..bdd1dc3 --- /dev/null +++ b/frontend/src/pages/ChampionshipPage.tsx @@ -0,0 +1,618 @@ +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { fetchChampionshipHub, fetchSeasons } from '../api' +import { teamColor } from '../utils' +import type { ChampHubDriver, ChampionshipHub } from '../types' + +type View = 'drivers' | 'constructors' | 'progression' + +const GOLD = '#ffd700' +const SILVER = '#c0c0c0' +const BRONZE = '#cd7f32' + +function medalColor(pos: number): string { + if (pos === 1) return GOLD + if (pos === 2) return SILVER + if (pos === 3) return BRONZE + return 'var(--text-2)' +} + +function ghostColor(pos: number): string { + if (pos === 1) return 'rgba(255,215,0,0.08)' + if (pos === 2) return 'rgba(192,192,192,0.07)' + if (pos === 3) return 'rgba(205,127,50,0.07)' + return 'rgba(255,255,255,0.03)' +} + +function fmtPts(n: number): string { + return Number.isInteger(n) ? String(n) : n.toFixed(1) +} + +/** Sparkline polyline points inside a 62×20 box. */ +function sparkPoints(form: number[]): string { + if (!form.length) return '' + const fmax = Math.max(25, ...form) + const n = form.length + return form + .map((v, k) => { + const x = n === 1 ? 0 : (k * 62) / (n - 1) + const y = 18 - (fmax > 0 ? (v / fmax) * 16 : 0) + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') +} + +interface TeamSplit { + driverA: string + ptsA: number + driverB: string + ptsB: number + splitA: number + splitB: number + shareLabel: string +} + +function teamSplit(teamName: string, drivers: ChampHubDriver[]): TeamSplit { + const ds = drivers.filter((d) => d.team_name === teamName).sort((a, b) => b.points - a.points) + const a = ds[0] + const b = ds[1] + const ptsA = a?.points ?? 0 + const ptsB = b?.points ?? 0 + const total = ptsA + ptsB || 1 + const splitA = Math.round((ptsA / total) * 100) + return { + driverA: a?.name_acronym ?? '—', + ptsA, + driverB: b?.name_acronym ?? '—', + ptsB, + splitA, + splitB: 100 - splitA, + shareLabel: `${splitA}/${100 - splitA}`, + } +} + +export function ChampionshipPage() { + const [view, setView] = useState('drivers') + + const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons }) + const latestSeason = seasonsQuery.data?.[0] ?? null + + const hubQuery = useQuery({ + queryKey: ['championship-hub', latestSeason], + queryFn: () => fetchChampionshipHub(latestSeason ?? undefined), + enabled: latestSeason != null, + staleTime: 5 * 60_000, + }) + + if (seasonsQuery.isLoading || hubQuery.isLoading) { + return
loading championship…
+ } + if (hubQuery.isError) { + return ( +
+ {hubQuery.error instanceof Error ? hubQuery.error.message : 'Failed to load championship'} +
+ ) + } + + const hub = hubQuery.data + if (!hub || hub.drivers.length === 0) { + return ( +
+
+ box-box · championship +

No championship data

+

+ Standings for {latestSeason ?? 'this season'} are not available yet. Once race results are + ingested they will appear here. +

+
+
+ ) + } + + return +} + +interface BodyProps { + hub: ChampionshipHub + view: View + setView: (v: View) => void +} + +function ChampionshipBody({ hub, view, setView }: BodyProps) { + const { drivers, teams } = hub + const leader = drivers[0] + const remaining = hub.rounds_left * 25 + + const enriched = useMemo( + () => + drivers.map((d, i) => { + const gapLeaderNum = leader.points - d.points + const gapAheadNum = i === 0 ? null : drivers[i - 1].points - d.points + const alive = i === 0 || gapLeaderNum <= remaining + return { + d, + pos: d.position, + color: teamColor(d.team_colour), + gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`, + gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`, + spark: sparkPoints(d.form), + h2h: `${d.teammate_wins}–${d.teammate_losses}`, + h2hWin: d.teammate_wins >= d.teammate_losses, + aliveLabel: i === 0 ? 'LEADS' : alive ? 'ALIVE' : 'OUT', + aliveColor: i === 0 ? GOLD : alive ? 'var(--green)' : 'var(--text-3)', + } + }), + [drivers, leader, remaining], + ) + + const aliveCount = enriched.filter((e) => e.aliveLabel === 'ALIVE' || e.aliveLabel === 'LEADS').length + + const titleMath = + `${aliveCount} driver${aliveCount === 1 ? '' : 's'} can still mathematically win the title. ` + + `With ${hub.rounds_left} round${hub.rounds_left === 1 ? '' : 's'} left (max ${remaining} pts), ` + + `${leader.name_acronym} leads ` + + (drivers[1] ? `${drivers[1].name_acronym} by ${fmtPts(leader.points - drivers[1].points)}` : 'the field') + + (drivers[2] ? ` and ${drivers[2].name_acronym} by ${fmtPts(leader.points - drivers[2].points)}.` : '.') + + const topTeam = teams[0] + const teamGap = teams[1] ? topTeam.points - teams[1].points : 0 + const seasonPct = hub.total_rounds > 0 ? Math.round((hub.round / hub.total_rounds) * 100) : 0 + + const statRail = [ + { label: 'Drivers Leader', value: leader.name_acronym, sub: `${fmtPts(leader.points)} pts · ${leader.wins} wins`, color: '#fff' }, + { + label: 'Constructors Leader', + value: topTeam?.team_name ?? '—', + sub: teams[1] ? `+${fmtPts(teamGap)} ahead` : 'Sole entry', + color: '#fff', + }, + { label: 'Title Fight', value: `${aliveCount} alive`, sub: `${hub.rounds_left} rounds remain`, color: 'var(--red)' }, + { label: 'Season Progress', value: `${seasonPct}%`, sub: `Round ${hub.round}/${hub.total_rounds}`, color: '#fff' }, + ] + + return ( +
+
+
+
+
+ {hub.last_race ? `After ${hub.last_race} · ` : ''}Round {hub.round} of {hub.total_rounds} +
+
+ + + +
+
+ +
+ {statRail.map((stat) => ( +
+
{stat.label}
+
+ {stat.value} +
+
{stat.sub}
+
+ ))} +
+ + {view === 'drivers' && ( + + )} + {view === 'constructors' && } + {view === 'progression' && } +
+ ) +} + +interface EnrichedDriver { + d: ChampHubDriver + pos: number + color: string + gapLeader: string + gapAhead: string + spark: string + h2h: string + h2hWin: boolean + aliveLabel: string + aliveColor: string +} + +function DriversView({ + enriched, + leaderPoints, + titleMath, +}: { + enriched: EnrichedDriver[] + leaderPoints: number + titleMath: string +}) { + const podium = enriched.slice(0, 3) + return ( +
+
+ {podium.map((e) => ( +
+ + P{e.pos} + +
+
+ + P{e.pos} + + {e.d.team_name} +
+
+ + {e.d.name_acronym} +
+
{e.d.full_name}
+
+ {fmtPts(e.d.points)} + PTS + + {e.pos === 1 ? 'P1' : `+${fmtPts(leaderPoints - e.d.points)}`} + +
+
+
+
{e.d.wins}
+
Wins
+
+
+
{e.d.podiums}
+
Podiums
+
+
+
{e.d.poles}
+
Poles
+
+
+
+
+ ))} +
+ +
+ Title Math + {titleMath} +
+ +
+ + + + + + + + + + + + + + + + + + {enriched.map((e) => ( + + + + + + + + + + + + + + ))} + +
PosDriverTeamPtsGapIntWinsPodFormvs TeammateTitle
+ P{e.pos} + +
+ + {e.d.name_acronym} + {e.d.full_name} + #{e.d.driver_number} +
+
{e.d.team_name}{fmtPts(e.d.points)}{e.gapLeader}{e.gapAhead} 0 ? 'var(--text)' : 'var(--text-3)' }}> + {e.d.wins} + {e.d.podiums} + {e.spark ? ( + + + + ) : ( + + )} + + {e.h2h} + + + {e.aliveLabel} + +
+
+
+ ) +} + +function ConstructorsView({ hub }: { hub: ChampionshipHub }) { + const { teams, drivers } = hub + const leaderPts = teams[0]?.points ?? 0 + const podium = teams.slice(0, 3) + + return ( +
+
+ {podium.map((t) => { + const color = teamColor(t.team_colour) + const split = teamSplit(t.team_name, drivers) + return ( +
+ + P{t.position} + +
+
+ + P{t.position} + +
+
+ + {t.team_name} +
+
+ {fmtPts(t.points)} + PTS + + {t.position === 1 ? 'P1' : `+${fmtPts(leaderPts - t.points)}`} + +
+
+
+ + +
+
+ + {split.driverA} {fmtPts(split.ptsA)} + + + {split.driverB} {fmtPts(split.ptsB)} + +
+
+
+
+ ) + })} +
+ +
+ + + + + + + + + + + + + + {teams.map((t) => { + const color = teamColor(t.team_colour) + const split = teamSplit(t.team_name, drivers) + return ( + + + + + + + + + + ) + })} + +
PosConstructorPtsGapWinsDriver ContributionShare
+ P{t.position} + +
+ + {t.team_name} +
+
{fmtPts(t.points)} + {t.position === 1 ? 'LEADER' : `+${fmtPts(leaderPts - t.points)}`} + 0 ? 'var(--text)' : 'var(--text-3)' }}> + {t.wins} + +
+
+ + +
+ + {split.driverA} + · + {split.driverB} + +
+
{split.shareLabel}
+
+
+ ) +} + +const CHART_PAD_L = 48 +const CHART_PAD_T = 16 +const CHART_PLOT_W = 882 +const CHART_PLOT_H = 316 + +function ProgressionView({ hub }: { hub: ChampionshipHub }) { + const top = hub.drivers.slice(0, 6).filter((d) => d.cumulative.length > 0) + + if (top.length === 0) { + return ( +
+ No completed rounds yet — progression will appear after the first race. +
+ ) + } + + const N = Math.max(...top.map((d) => d.cumulative.length)) + const peak = Math.max(...top.flatMap((d) => d.cumulative)) + const maxY = Math.max(100, Math.ceil(peak / 100) * 100) + + const x = (i: number) => (N <= 1 ? CHART_PAD_L : CHART_PAD_L + (i * CHART_PLOT_W) / (N - 1)) + const y = (v: number) => CHART_PAD_T + CHART_PLOT_H - (v / maxY) * CHART_PLOT_H + + const yGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => { + const value = Math.round(maxY * f) + const yy = y(value) + return { y: yy, label: value } + }) + + // x ticks: up to ~7 evenly spaced round labels. + const tickStep = Math.max(1, Math.ceil(N / 7)) + const xGrid: { x: number; label: string }[] = [] + for (let i = 0; i < N; i += tickStep) { + xGrid.push({ x: x(i), label: hub.round_labels[i] ?? `R${i + 1}` }) + } + if (xGrid[xGrid.length - 1]?.label !== (hub.round_labels[N - 1] ?? `R${N}`)) { + xGrid.push({ x: x(N - 1), label: hub.round_labels[N - 1] ?? `R${N}` }) + } + + const seenTeams = new Set() + const series = top.map((d, idx) => { + const dashed = seenTeams.has(d.team_name) + seenTeams.add(d.team_name) + const pts = d.cumulative.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ') + const endVal = d.cumulative[d.cumulative.length - 1] + const endX = x(d.cumulative.length - 1) + const endY = y(endVal) + return { + code: d.name_acronym, + name: d.full_name, + total: d.points, + color: teamColor(d.team_colour), + width: idx < 3 ? 2.4 : 1.8, + dash: dashed ? '5 4' : '0', + points: pts, + endX, + endY, + } + }) + + return ( +
+
+ Cumulative points — top {top.length} drivers + + Rounds 1–{hub.round} · {hub.season} + +
+
+ + {yGrid.map((g) => ( + + + + {g.label} + + + ))} + {xGrid.map((g, i) => ( + + {g.label} + + ))} + {series.map((s) => ( + + + + + {s.code} + + + ))} + +
+
+ {series.map((s) => ( +
+ + {s.code} + {s.name} + {fmtPts(s.total)} +
+ ))} +
+
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 3756ee9..a0227d3 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -5,6 +5,7 @@ import { RaceHubPage } from './pages/RaceHubPage' import { DataLibraryPage } from './pages/DataLibraryPage' import { LiveTimingPage } from './pages/LiveTimingPage' import { BriefingPage } from './pages/BriefingPage' +import { ChampionshipPage } from './pages/ChampionshipPage' type RaceHubSearch = { session_key?: number @@ -57,6 +58,12 @@ export const liveTimingRoute = createRoute({ component: LiveTimingPage, }) +export const championshipRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/championship', + component: ChampionshipPage, +}) + export const briefingRoute = createRoute({ getParentRoute: () => rootRoute, path: '/briefing', @@ -69,6 +76,7 @@ const routeTree = rootRoute.addChildren([ adminRoute, dataLibraryRoute, liveTimingRoute, + championshipRoute, briefingRoute, ]) diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index e47ee33..71e67ed 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -3200,3 +3200,513 @@ a { color: inherit; text-decoration: none; } background: var(--surface-h); border-color: var(--border-2); } + +/* ══════════════════════ Championship Hub ══════════════════════ */ +.champ-page { + max-width: 1040px; + margin: 0 auto; + padding: var(--s5) var(--s6) 56px; +} + +/* header */ +.champ-header { + display: flex; + flex-direction: column; + gap: var(--s3); + padding-bottom: var(--s4); + border-bottom: 1px solid var(--border); +} +.champ-title-row { + display: flex; + align-items: center; + gap: var(--s4); +} +.champ-accent { + width: 3px; + height: 22px; + background: var(--red); + border-radius: 1px; +} +.champ-title { + font-size: 22px; + font-weight: 700; + line-height: 1; + letter-spacing: -0.01em; +} +.champ-season { + font-size: 11px; + font-weight: 700; + color: var(--bg); + background: var(--red); + padding: 2px 6px; + border-radius: 2px; + letter-spacing: 0.04em; +} +.champ-sub { + font-size: 13px; + color: var(--text-2); +} +.champ-tabs { + display: flex; + gap: 2px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; + padding: 2px; + align-self: flex-start; +} +.champ-tab { + padding: 5px 14px; + font-size: 11px; + font-weight: 600; + font-family: var(--f-ui); + border: none; + border-radius: 2px; + cursor: pointer; + background: transparent; + color: var(--text-2); +} +.champ-tab:hover { + color: var(--text); +} +.champ-tab.is-active { + background: var(--red); + color: #fff; + font-weight: 700; +} + +/* stat rail */ +.champ-stat-rail { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; + margin-top: var(--s5); +} +.champ-stat { + background: var(--surface); + padding: 12px 14px; +} +.champ-stat-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-3); +} +.champ-stat-value { + font-size: 18px; + font-weight: 700; + margin-top: 4px; + line-height: 1; +} +.champ-stat-sub { + font-size: 11px; + color: var(--text-2); + margin-top: 4px; +} + +/* podium hero cards */ +.champ-podium { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-top: 20px; +} +.champ-podium-card { + position: relative; + background: linear-gradient(160deg, #161616, #101010); + border: 1px solid var(--border); + border-top: 2px solid var(--text-2); + border-radius: 3px; + padding: 16px; + overflow: hidden; +} +.champ-podium-ghost { + position: absolute; + top: -18px; + right: -6px; + font-size: 84px; + font-weight: 800; + line-height: 1; + pointer-events: none; +} +.champ-podium-inner { + position: relative; +} +.champ-podium-top { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} +.champ-podium-team { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-3); +} +.champ-podium-id { + display: flex; + align-items: baseline; + gap: 8px; + margin-top: 10px; +} +.champ-podium-bar { + width: 4px; + height: 30px; + border-radius: 1px; + align-self: center; +} +.champ-podium-code { + font-size: 30px; + font-weight: 800; + color: #fff; + letter-spacing: -0.02em; + line-height: 1; +} +.champ-podium-team-name { + font-size: 19px; + font-weight: 700; + color: #fff; + letter-spacing: -0.01em; +} +.champ-podium-name { + font-size: 13px; + color: #b8b8b8; + margin-top: 6px; +} +.champ-podium-pts { + display: flex; + align-items: baseline; + gap: 6px; + margin-top: 14px; +} +.champ-podium-pts-num { + font-size: 26px; + font-weight: 800; + color: var(--text); + line-height: 1; +} +.champ-podium-pts-unit { + font-size: 11px; + color: var(--text-3); +} +.champ-podium-gap { + margin-left: auto; + font-size: 12px; +} +.champ-podium-stats { + display: flex; + gap: 14px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.champ-podium-stat-num { + font-size: 14px; + font-weight: 700; + color: var(--text); +} +.champ-podium-stat-label { + font-size: 10px; + color: var(--text-3); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +/* contribution split */ +.champ-contrib { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.champ-contrib-bar { + display: flex; + height: 6px; + border-radius: 3px; + overflow: hidden; + background: var(--bg); +} +.champ-contrib-row { + display: flex; + align-items: center; + gap: 8px; +} +.champ-contrib-row .champ-contrib-bar { + flex: 1; + max-width: 160px; +} +.champ-contrib-legend { + display: flex; + justify-content: space-between; + margin-top: 6px; + font-size: 11px; + color: #b8b8b8; +} +.champ-contrib-legend em { + color: var(--text-3); + font-style: normal; +} +.champ-contrib-names { + font-size: 11px; + color: var(--text-2); +} +.champ-contrib-names em { + color: var(--text-3); + font-style: normal; +} + +/* title math */ +.champ-titlemath { + display: flex; + align-items: flex-start; + gap: 12px; + margin-top: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-left: 2px solid var(--red); + border-radius: 3px; + padding: 12px 16px; +} +.champ-titlemath-tag { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--red); + padding-top: 2px; + white-space: nowrap; +} +.champ-titlemath-text { + font-size: 13px; + color: #b8b8b8; +} + +/* tables */ +.champ-scroll { + margin-top: var(--s5); + overflow-x: auto; +} +.champ-scroll::-webkit-scrollbar { + height: 8px; +} +.champ-scroll::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} +.champ-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.champ-table-drivers { + min-width: 760px; +} +.champ-table-teams { + min-width: 680px; +} +.champ-table th { + padding: 6px 8px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-3); + border-bottom: 1px solid var(--border); +} +.champ-table th.l, +.champ-table td.l { + text-align: left; +} +.champ-table th.r, +.champ-table td.r { + text-align: right; +} +.champ-table th.c, +.champ-table td.c { + text-align: center; +} +.champ-table td { + padding: 7px 8px; + border-bottom: 1px solid #1c1c1c; +} +.champ-table tbody tr:hover { + background: var(--surface-h); +} +.champ-td-pts { + font-weight: 700; + color: var(--text); +} +.champ-td-muted { + color: var(--text-2); +} +.champ-td-dim { + color: var(--text-3); +} +.champ-td-team { + font-size: 11px; + color: var(--text-2); +} +.champ-drv { + display: flex; + align-items: center; + gap: 5px; +} +.champ-drv-bar { + width: 3px; + height: 18px; + border-radius: 1px; +} +.champ-drv-code { + font-size: 12px; + font-weight: 700; + color: var(--text); + min-width: 34px; +} +.champ-drv-name { + font-size: 12px; + color: var(--text-2); +} +.champ-drv-num { + font-size: 10px; + color: var(--text-3); +} +.champ-team-name { + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.champ-spark { + display: block; +} +.champ-alive { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +/* progression chart */ +.champ-progression { + margin-top: 20px; +} +.champ-chart-head { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; +} +.champ-chart-title { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-3); +} +.champ-chart-meta { + font-size: 11px; + color: var(--text-2); +} +.champ-chart { + background: #101010; + border: 1px solid var(--border); + border-radius: 3px; + padding: 14px; + margin-top: 10px; +} +.champ-chart-svg { + width: 100%; + height: auto; + display: block; +} +.champ-chart-axis { + fill: var(--text-3); + font-family: var(--f-mono); + font-size: 11px; +} +.champ-chart-label { + font-family: var(--f-mono); + font-size: 12px; + font-weight: 700; +} +.champ-chart-empty { + margin-top: 20px; + padding: 40px; + text-align: center; + font-size: 13px; + color: var(--text-2); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; +} +.champ-legend { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-top: 12px; +} +.champ-legend-item { + display: flex; + align-items: center; + gap: 6px; +} +.champ-legend-swatch { + width: 14px; + height: 2px; + border-radius: 1px; +} +.champ-legend-code { + font-size: 11px; + font-weight: 700; + color: var(--text); +} +.champ-legend-name { + font-size: 11px; + color: var(--text-2); +} +.champ-legend-total { + font-size: 11px; + color: var(--text-3); +} + +/* empty state */ +.champ-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: calc(100vh - var(--nav-h) - 80px); + text-align: center; +} +.champ-empty-eyebrow { + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-3); +} +.champ-empty-title { + font-size: 22px; + font-weight: 700; + margin-top: 10px; +} +.champ-empty-sub { + font-size: 13px; + color: var(--text-2); + margin-top: 8px; + max-width: 460px; +} + +@media (max-width: 720px) { + .champ-stat-rail { + grid-template-columns: repeat(2, 1fr); + } + .champ-podium { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/test/ChampionshipPage.test.tsx b/frontend/src/test/ChampionshipPage.test.tsx new file mode 100644 index 0000000..61261ab --- /dev/null +++ b/frontend/src/test/ChampionshipPage.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router' +import { ChampionshipPage } from '../pages/ChampionshipPage' +import type { ChampHubDriver, ChampHubTeam, ChampionshipHub } from '../types' + +vi.mock('../api', () => ({ + fetchSeasons: vi.fn(), + fetchChampionshipHub: vi.fn(), +})) + +import { fetchSeasons, fetchChampionshipHub } from '../api' + +const mockFetchSeasons = vi.mocked(fetchSeasons) +const mockFetchHub = vi.mocked(fetchChampionshipHub) + +function driver(over: Partial): ChampHubDriver { + return { + driver_number: 1, + name_acronym: 'VER', + full_name: 'Max Verstappen', + team_name: 'Red Bull', + team_colour: '3671c6', + points: 200, + position: 1, + wins: 5, + podiums: 8, + poles: 4, + form: [25, 18, 25, 15, 25], + cumulative: [25, 43, 68, 83, 108, 200], + teammate_wins: 9, + teammate_losses: 1, + ...over, + } +} + +const drivers: ChampHubDriver[] = [ + driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200, position: 1 }), + driver({ + driver_number: 4, + name_acronym: 'NOR', + full_name: 'Lando Norris', + team_name: 'McLaren', + team_colour: 'ff8000', + points: 160, + position: 2, + wins: 3, + cumulative: [18, 36, 54, 80, 120, 160], + }), + driver({ + driver_number: 16, + name_acronym: 'LEC', + full_name: 'Charles Leclerc', + team_name: 'Ferrari', + team_colour: 'e8002d', + points: 120, + position: 3, + wins: 1, + cumulative: [15, 28, 40, 60, 90, 120], + }), +] + +const teams: ChampHubTeam[] = [ + { team_name: 'Red Bull', team_colour: '3671c6', points: 260, position: 1, wins: 6 }, + { team_name: 'McLaren', team_colour: 'ff8000', points: 220, position: 2, wins: 3 }, + { team_name: 'Ferrari', team_colour: 'e8002d', points: 180, position: 3, wins: 1 }, +] + +const hub: ChampionshipHub = { + season: 2025, + round: 6, + total_rounds: 10, + rounds_left: 4, + last_race: 'Monaco GP', + round_labels: ['R1', 'R2', 'R3', 'R4', 'R5', 'R6'], + drivers, + teams, +} + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const rootRoute = createRootRoute({ + component: () => ( + + + + ), + }) + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: ChampionshipPage }) + const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) }) + return render() +} + +describe('ChampionshipPage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchSeasons.mockResolvedValue([2025]) + mockFetchHub.mockResolvedValue(hub) + }) + + it('renders the drivers view with leader and title math', async () => { + renderPage() + + await waitFor(() => { + expect(screen.getByTestId('championship')).toBeInTheDocument() + }) + + expect(screen.getByTestId('champ-view-drivers')).toBeInTheDocument() + // Leader code shows in the stat rail and the table. + expect(screen.getAllByText('VER').length).toBeGreaterThan(0) + expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument() + expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title') + }) + + it('switches to constructors and progression views', async () => { + renderPage() + + await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument()) + + fireEvent.click(screen.getByTestId('champ-tab-constructors')) + expect(screen.getByTestId('champ-view-constructors')).toBeInTheDocument() + expect(screen.getAllByText('Red Bull', { exact: false }).length).toBeGreaterThan(0) + + fireEvent.click(screen.getByTestId('champ-tab-progression')) + expect(screen.getByTestId('champ-view-progression')).toBeInTheDocument() + expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument() + }) + + it('shows the empty state when no drivers are returned', async () => { + mockFetchHub.mockResolvedValue({ ...hub, drivers: [], teams: [] }) + renderPage() + + await waitFor(() => { + expect(screen.getByTestId('championship-empty')).toBeInTheDocument() + }) + expect(screen.getByText('No championship data')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 53a6fd6..c3d5222 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -267,6 +267,42 @@ export interface LiveStreamData { Stints: Record } +export interface ChampHubDriver { + driver_number: number + name_acronym: string + full_name: string + team_name: string + team_colour: string + points: number + position: number + wins: number + podiums: number + poles: number + form: number[] + cumulative: number[] + teammate_wins: number + teammate_losses: number +} + +export interface ChampHubTeam { + team_name: string + team_colour: string + points: number + position: number + wins: number +} + +export interface ChampionshipHub { + season: number + round: number + total_rounds: number + rounds_left: number + last_race: string + round_labels: string[] + drivers: ChampHubDriver[] + teams: ChampHubTeam[] +} + export interface NewsItem { source: string title: string diff --git a/internal/web/api.go b/internal/web/api.go index 9c84c01..e44c3e4 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -597,6 +597,292 @@ func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request) writeJSON(w, teams) } +// --- /api/v1/championship/hub --- +// Aggregated championship view: official points/positions enriched with derived +// stats (wins, podiums, poles, recent form, teammate head-to-head) and a +// per-round cumulative-points series, computed from season race results. + +type champHubDriver struct { + 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"` + Points float64 `json:"points"` + Position int `json:"position"` + Wins int `json:"wins"` + Podiums int `json:"podiums"` + Poles int `json:"poles"` + Form []float64 `json:"form"` // last 5 races' points + Cumulative []float64 `json:"cumulative"` // running total per completed round + TeammateWins int `json:"teammate_wins"` + TeammateLosses int `json:"teammate_losses"` +} + +type champHubTeam struct { + TeamName string `json:"team_name"` + TeamColour string `json:"team_colour"` + Points float64 `json:"points"` + Position int `json:"position"` + Wins int `json:"wins"` +} + +type champHubResponse struct { + Season int `json:"season"` + Round int `json:"round"` + TotalRounds int `json:"total_rounds"` + RoundsLeft int `json:"rounds_left"` + LastRace string `json:"last_race"` + RoundLabels []string `json:"round_labels"` + Drivers []champHubDriver `json:"drivers"` + Teams []champHubTeam `json:"teams"` +} + +// meetingRace bundles a GP meeting with its (already-fetched) race results and grid. +type meetingRace struct { + Meeting models.Meeting + RaceSessionKey int + Results []models.SessionResult + Grid []models.StartingGrid +} + +func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) { + year, _ := strconv.Atoi(r.URL.Query().Get("year")) + if year == 0 { + year = time.Now().Year() + } + + meetings, err := s.client.GetMeetingsForYear(year) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + + champ, err := s.client.GetDriverChampionshipForYear(year) + if err != nil { + writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) + return + } + if len(champ) == 0 { + writeJSON(w, champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}) + return + } + teams, _ := s.client.GetTeamChampionshipForYear(year) + + driverInfo := map[int]models.Driver{} + if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil { + driverInfo = buildDriverMapFirst(ds) + } + + sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart }) + + races := make([]meetingRace, 0, len(meetings)) + for _, m := range meetings { + sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey)) + if serr != nil { + continue + } + raceKey := 0 + for _, sess := range sessions { + if strings.EqualFold(sess.SessionName, "Race") { + raceKey = sess.SessionKey + break + } + } + if raceKey == 0 { + continue // not a GP meeting (e.g. pre-season testing) + } + results, _ := s.client.GetSessionResult(raceKey) + grid, _ := s.client.GetStartingGrid(raceKey) + races = append(races, meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}) + } + + writeJSON(w, aggregateChampionshipHub(year, races, champ, teams, driverInfo)) +} + +// aggregateChampionshipHub is the pure aggregation core (no network) so it can be +// unit-tested with synthetic data. races must be ordered ascending by date and +// contain only GP meetings (those with a Race session). +func aggregateChampionshipHub( + year int, + races []meetingRace, + champ []models.ChampionshipDriver, + teams []models.ChampionshipTeam, + driverInfo map[int]models.Driver, +) champHubResponse { + type acc struct { + wins, podiums, poles int + form []float64 + finishByRound map[int]int + } + accs := map[int]*acc{} + getAcc := func(num int) *acc { + a := accs[num] + if a == nil { + a = &acc{finishByRound: map[int]int{}} + accs[num] = a + } + return a + } + + completed := 0 + lastRace := "" + roundPoints := []map[int]float64{} // per completed round: driver -> race points + + for _, mr := range races { + if len(mr.Results) == 0 { + continue // round not completed yet + } + completed++ + lastRace = mr.Meeting.MeetingName + for _, g := range mr.Grid { + if g.Position == 1 { + getAcc(g.DriverNumber).poles++ + } + } + rp := map[int]float64{} + for _, res := range mr.Results { + a := getAcc(res.DriverNumber) + if res.Position == 1 { + a.wins++ + } + if res.Position >= 1 && res.Position <= 3 { + a.podiums++ + } + a.form = append(a.form, res.Points) + a.finishByRound[completed] = res.Position + rp[res.DriverNumber] += res.Points + } + roundPoints = append(roundPoints, rp) + } + + roundLabels := make([]string, 0, completed) + for i := 1; i <= completed; i++ { + roundLabels = append(roundLabels, fmt.Sprintf("R%d", i)) + } + + // Official totals are authoritative; reconcile the cumulative endpoint to them. + champPts := map[int]float64{} + for _, c := range champ { + champPts[c.DriverNumber] = c.PointsCurrent + } + + cumulative := map[int][]float64{} + for num := range accs { + running := 0.0 + series := make([]float64, 0, completed) + for i := 0; i < completed; i++ { + running += roundPoints[i][num] + series = append(series, running) + } + if completed > 0 { + if off, ok := champPts[num]; ok { + series[completed-1] = off + } + } + cumulative[num] = series + } + + // Teammate head-to-head: per round, the teammate finishing ahead wins. + teamOf := func(num int) string { return driverInfo[num].TeamName } + byTeam := map[string][]int{} + for num := range accs { + byTeam[teamOf(num)] = append(byTeam[teamOf(num)], num) + } + twins := map[int]int{} + tloss := map[int]int{} + for team, members := range byTeam { + if team == "" || len(members) < 2 { + continue + } + for round := 1; round <= completed; round++ { + for i := 0; i < len(members); i++ { + for j := i + 1; j < len(members); j++ { + p1, ok1 := accs[members[i]].finishByRound[round] + p2, ok2 := accs[members[j]].finishByRound[round] + if !ok1 || !ok2 { + continue + } + if p1 < p2 { + twins[members[i]]++ + tloss[members[j]]++ + } else if p2 < p1 { + twins[members[j]]++ + tloss[members[i]]++ + } + } + } + } + } + + sortedChamp := make([]models.ChampionshipDriver, len(champ)) + copy(sortedChamp, champ) + sort.Slice(sortedChamp, func(i, j int) bool { return sortedChamp[i].PositionCurrent < sortedChamp[j].PositionCurrent }) + + drivers := make([]champHubDriver, 0, len(sortedChamp)) + for _, c := range sortedChamp { + a := accs[c.DriverNumber] + if a == nil { + a = &acc{} + } + form := a.form + if len(form) > 5 { + form = form[len(form)-5:] + } + info := driverInfo[c.DriverNumber] + drivers = append(drivers, champHubDriver{ + DriverNumber: c.DriverNumber, + NameAcronym: info.NameAcronym, + FullName: info.FullName, + TeamName: info.TeamName, + TeamColour: info.TeamColour, + Points: c.PointsCurrent, + Position: c.PositionCurrent, + Wins: a.wins, + Podiums: a.podiums, + Poles: a.poles, + Form: form, + Cumulative: cumulative[c.DriverNumber], + TeammateWins: twins[c.DriverNumber], + TeammateLosses: tloss[c.DriverNumber], + }) + } + + teamWins := map[string]int{} + teamColour := map[string]string{} + for num, a := range accs { + teamWins[teamOf(num)] += a.wins + if col := driverInfo[num].TeamColour; col != "" { + teamColour[teamOf(num)] = col + } + } + sortedTeams := make([]models.ChampionshipTeam, len(teams)) + copy(sortedTeams, teams) + sort.Slice(sortedTeams, func(i, j int) bool { return sortedTeams[i].PositionCurrent < sortedTeams[j].PositionCurrent }) + teamsOut := make([]champHubTeam, 0, len(sortedTeams)) + for _, t := range sortedTeams { + teamsOut = append(teamsOut, champHubTeam{ + TeamName: t.TeamName, + TeamColour: teamColour[t.TeamName], + Points: t.PointsCurrent, + Position: t.PositionCurrent, + Wins: teamWins[t.TeamName], + }) + } + + totalRounds := len(races) + return champHubResponse{ + Season: year, + Round: completed, + TotalRounds: totalRounds, + RoundsLeft: totalRounds - completed, + LastRace: lastRace, + RoundLabels: roundLabels, + Drivers: drivers, + Teams: teamsOut, + } +} + // --- /api/v1/track-outline --- // Accepts circuit_key and year (the frontend has both from meeting+session data). diff --git a/internal/web/championship_hub_test.go b/internal/web/championship_hub_test.go new file mode 100644 index 0000000..d0a6281 --- /dev/null +++ b/internal/web/championship_hub_test.go @@ -0,0 +1,142 @@ +package web + +import ( + "testing" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +func raceResult(num, pos int, pts float64) models.SessionResult { + return models.SessionResult{DriverNumber: num, Position: pos, Points: pts} +} + +func TestAggregateChampionshipHub(t *testing.T) { + driverInfo := map[int]models.Driver{ + 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"}, + } + + 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}, + } + 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. + races := []meetingRace{ + { + Meeting: models.Meeting{MeetingName: "Bahrain GP"}, + Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 2, 18), raceResult(2, 3, 15)}, + Grid: []models.StartingGrid{{DriverNumber: 1, Position: 1}}, + }, + { + Meeting: models.Meeting{MeetingName: "Saudi GP"}, + Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15)}, + Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}}, + }, + // Round 3: not yet run (no results) — should not count as completed. + {Meeting: models.Meeting{MeetingName: "Australia GP"}}, + } + + resp := aggregateChampionshipHub(2025, races, champ, teams, driverInfo) + + if resp.Season != 2025 { + t.Errorf("season = %d, want 2025", resp.Season) + } + if resp.Round != 2 { + t.Errorf("completed rounds = %d, want 2", resp.Round) + } + if resp.TotalRounds != 3 { + t.Errorf("total rounds = %d, want 3", resp.TotalRounds) + } + if resp.RoundsLeft != 1 { + t.Errorf("rounds left = %d, want 1", resp.RoundsLeft) + } + if resp.LastRace != "Saudi GP" { + t.Errorf("last race = %q, want Saudi GP", resp.LastRace) + } + if len(resp.RoundLabels) != 2 || resp.RoundLabels[0] != "R1" || resp.RoundLabels[1] != "R2" { + 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)) + } + ver := resp.Drivers[0] + if ver.NameAcronym != "VER" || ver.Position != 1 { + t.Errorf("first driver = %s P%d, want VER P1", ver.NameAcronym, ver.Position) + } + if ver.Wins != 2 { + t.Errorf("VER wins = %d, want 2", ver.Wins) + } + if ver.Podiums != 2 { + t.Errorf("VER podiums = %d, want 2", ver.Podiums) + } + if ver.Poles != 1 { + t.Errorf("VER poles = %d, want 1", ver.Poles) + } + if len(ver.Form) != 2 || ver.Form[0] != 25 || ver.Form[1] != 25 { + t.Errorf("VER form = %v, want [25 25]", ver.Form) + } + // Cumulative reconciles final value to official total (50). + if len(ver.Cumulative) != 2 || ver.Cumulative[0] != 25 || ver.Cumulative[1] != 50 { + t.Errorf("VER cumulative = %v, want [25 50]", ver.Cumulative) + } + // 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) + } + + // PER lost both intra-team battles to VER. + var per champHubDriver + for _, d := range resp.Drivers { + if d.NameAcronym == "PER" { + per = d + } + } + if per.TeammateWins != 0 || per.TeammateLosses != 2 { + t.Errorf("PER h2h = %d-%d, want 0-2", per.TeammateWins, per.TeammateLosses) + } + if per.Poles != 0 { + t.Errorf("PER poles = %d, want 0", per.Poles) + } + + // HAM has no teammate in the data — no h2h recorded. + var ham champHubDriver + for _, d := range resp.Drivers { + if d.NameAcronym == "HAM" { + ham = d + } + } + if ham.TeammateWins != 0 || ham.TeammateLosses != 0 { + t.Errorf("HAM h2h = %d-%d, want 0-0 (no teammate)", ham.TeammateWins, ham.TeammateLosses) + } + if ham.Poles != 1 { + t.Errorf("HAM poles = %d, want 1", ham.Poles) + } + + // 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)) + } + if resp.Teams[0].TeamName != "Red Bull" || resp.Teams[0].Wins != 2 { + t.Errorf("top team = %s wins %d, want Red Bull wins 2", resp.Teams[0].TeamName, resp.Teams[0].Wins) + } + if resp.Teams[0].TeamColour != "3671c6" { + t.Errorf("Red Bull colour = %q, want 3671c6", resp.Teams[0].TeamColour) + } +} + +func TestAggregateChampionshipHubEmpty(t *testing.T) { + resp := aggregateChampionshipHub(2025, nil, nil, nil, map[int]models.Driver{}) + if resp.Round != 0 || resp.TotalRounds != 0 || len(resp.Drivers) != 0 { + t.Errorf("empty aggregation should be zero-valued, got %+v", resp) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index ca348a6..938b262 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -81,6 +81,7 @@ func (s *Server) routes() (http.Handler, error) { mux.HandleFunc("/api/v1/team-radio", s.handleTeamRadio) mux.HandleFunc("/api/v1/championship/drivers", s.handleChampionshipDrivers) mux.HandleFunc("/api/v1/championship/teams", s.handleChampionshipTeams) + mux.HandleFunc("/api/v1/championship/hub", s.handleChampionshipHub) mux.HandleFunc("/api/v1/track-outline", s.handleTrackOutline) mux.HandleFunc("/api/v1/strategy", s.handleStrategy) mux.HandleFunc("/api/v1/live/state", s.handleLiveState)