Files
box-box/frontend/src/pages/ChampionshipPage.tsx
2026-07-12 12:38:00 -04:00

705 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { TeammateH2H } from '../components/TeammateH2H'
import { teammatePairs } from '../lib/h2h'
import { pointsGapMeaning } from '../lib/meaning'
type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
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<View>('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 <div className="page loading-state">loading championship</div>
}
if (hubQuery.isError) {
return (
<div className="page error-box">
{hubQuery.error instanceof Error ? hubQuery.error.message : 'Failed to load championship'}
</div>
)
}
const hub = hubQuery.data
if (!hub || hub.drivers.length === 0) {
return (
<div className="champ-page champ-empty" data-testid="championship-empty">
<div className="champ-empty-band">
<span className="champ-empty-eyebrow mono">box-box · championship</span>
<h1 className="champ-empty-title">No championship data</h1>
<p className="champ-empty-sub">
Standings for {latestSeason ?? 'this season'} are not available yet. Once race results are
ingested they will appear here.
</p>
</div>
</div>
)
}
return <ChampionshipBody hub={hub} view={view} setView={setView} />
}
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<EnrichedDriver[]>(
() =>
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)}`,
gapAheadNum,
driverAhead: i === 0 ? null : drivers[i - 1].name_acronym,
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 (
<div className="champ-page" data-testid="championship">
<div className="champ-header">
<div className="champ-title-row">
<span className="champ-accent" aria-hidden="true" />
<h1 className="champ-title">Championship</h1>
<span className="champ-season mono">{hub.season}</span>
</div>
<div className="champ-sub">
{hub.last_race ? `After ${hub.last_race} · ` : ''}Round {hub.round} of {hub.total_rounds}
</div>
<div className="champ-tabs" role="tablist">
<button
type="button"
className={`champ-tab${view === 'drivers' ? ' is-active' : ''}`}
onClick={() => setView('drivers')}
data-testid="champ-tab-drivers"
>
Drivers
</button>
<button
type="button"
className={`champ-tab${view === 'constructors' ? ' is-active' : ''}`}
onClick={() => setView('constructors')}
data-testid="champ-tab-constructors"
>
Constructors
</button>
<button
type="button"
className={`champ-tab${view === 'progression' ? ' is-active' : ''}`}
onClick={() => setView('progression')}
data-testid="champ-tab-progression"
>
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' : ''}`}
onClick={() => setView('simulator')}
data-testid="champ-tab-simulator"
>
Simulator
</button>
</div>
</div>
<div className="champ-stat-rail">
{statRail.map((stat) => (
<div className="champ-stat" key={stat.label}>
<div className="champ-stat-label mono">{stat.label}</div>
<div className="champ-stat-value mono" style={{ color: stat.color }}>
{stat.value}
</div>
<div className="champ-stat-sub">{stat.sub}</div>
</div>
))}
</div>
{view === 'drivers' && (
<DriversView
enriched={enriched}
leaderPoints={leader.points}
titleMath={titleMath}
roundsLeft={hub.rounds_left}
drivers={drivers}
season={hub.season}
/>
)}
{view === 'constructors' && <ConstructorsView hub={hub} />}
{view === 'progression' && <ProgressionView hub={hub} />}
{view === 'rivalry' && <RivalryCompare hub={hub} />}
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
</div>
)
}
interface EnrichedDriver {
d: ChampHubDriver
pos: number
color: string
gapLeader: string
gapAhead: string
gapAheadNum: number | null
driverAhead: string | null
spark: string
h2h: string
h2hWin: boolean
aliveLabel: string
aliveColor: string
}
function DriversView({
enriched,
leaderPoints,
titleMath,
roundsLeft,
drivers,
season,
}: {
enriched: EnrichedDriver[]
leaderPoints: number
titleMath: string
roundsLeft: number
drivers: ChampHubDriver[]
season: number
}) {
const podium = enriched.slice(0, 3)
const h2hPairs = useMemo(() => teammatePairs(drivers), [drivers])
return (
<div data-testid="champ-view-drivers">
<div className="champ-podium">
{podium.map((e) => (
<div
className="champ-podium-card"
key={e.d.driver_number}
style={{ borderTopColor: e.color }}
>
<span className="champ-podium-ghost mono" style={{ color: ghostColor(e.pos) }}>
P{e.pos}
</span>
<div className="champ-podium-inner">
<div className="champ-podium-top">
<span className="mono" style={{ color: medalColor(e.pos), fontWeight: 700 }}>
P{e.pos}
</span>
<span className="champ-podium-team mono">{e.d.team_name}</span>
</div>
<div className="champ-podium-id">
<span className="champ-podium-bar" style={{ background: e.color }} />
<span className="champ-podium-code mono">{e.d.name_acronym}</span>
</div>
<div className="champ-podium-name">{e.d.full_name}</div>
<div className="champ-podium-pts">
<span className="champ-podium-pts-num mono">{fmtPts(e.d.points)}</span>
<span className="champ-podium-pts-unit">PTS</span>
<span
className="champ-podium-gap mono"
style={{ color: e.pos === 1 ? GOLD : 'var(--text-2)' }}
>
{e.pos === 1 ? 'P1' : `+${fmtPts(leaderPoints - e.d.points)}`}
</span>
</div>
<div className="champ-podium-stats">
<div>
<div className="champ-podium-stat-num mono">{e.d.wins}</div>
<div className="champ-podium-stat-label">Wins</div>
</div>
<div>
<div className="champ-podium-stat-num mono">{e.d.podiums}</div>
<div className="champ-podium-stat-label">Podiums</div>
</div>
<div>
<div className="champ-podium-stat-num mono">{e.d.poles}</div>
<div className="champ-podium-stat-label">Poles</div>
</div>
</div>
</div>
</div>
))}
</div>
<div className="champ-titlemath" data-testid="champ-titlemath">
<span className="champ-titlemath-tag mono">Title Math</span>
<span className="champ-titlemath-text">{titleMath}</span>
</div>
{h2hPairs.length > 0 && (
<section className="h2h-section" data-testid="champ-teammate-battles">
<div className="h2h-section-head">
<h2 className="h2h-section-title">Teammate battles</h2>
<span className="h2h-section-sub">race finishes · closest first</span>
</div>
<div className="h2h-list">
{h2hPairs.map((pair) => (
<TeammateH2H
key={pair.teamName}
teamName={pair.teamName}
teamColour={pair.teamColour}
driverATla={pair.driverA.name_acronym}
driverBTla={pair.driverB.name_acronym}
winsA={pair.driverA.teammate_wins}
winsB={pair.driverB.teammate_wins}
extraNote={pair.extraCount > 0 ? `+${pair.extraCount}` : undefined}
/>
))}
</div>
</section>
)}
<div className="champ-scroll">
<table className="champ-table champ-table-drivers">
<thead>
<tr>
<th className="l">Pos</th>
<th className="l">Driver</th>
<th className="l">Team</th>
<th className="r">Pts</th>
<th className="r">Gap</th>
<th className="r">Int</th>
<th className="c">Wins</th>
<th className="c">Pod</th>
<th className="l">Form</th>
<th className="c">vs Teammate</th>
<th className="r">Title</th>
</tr>
</thead>
<tbody>
{enriched.map((e) => (
<tr key={e.d.driver_number}>
<td className="mono" style={{ color: medalColor(e.pos), fontWeight: 700 }}>
P{e.pos}
</td>
<td>
<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>
</Link>
</td>
<td className="champ-td-team">{e.d.team_name}</td>
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
<td className="r mono champ-td-muted">{e.gapLeader}</td>
<td className="r mono champ-td-dim">
{(() => {
const gapAnnotation = pointsGapMeaning(e.gapAheadNum, roundsLeft, e.driverAhead)
return (
<Meaning
value={e.gapAhead}
meaning={gapAnnotation?.caption}
title={gapAnnotation?.title}
tone={gapAnnotation?.tone}
/>
)
})()}
</td>
<td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{e.d.wins}
</td>
<td className="c mono champ-td-muted">{e.d.podiums}</td>
<td>
{e.spark ? (
<svg width="62" height="20" viewBox="0 0 62 20" className="champ-spark">
<polyline
points={e.spark}
fill="none"
stroke={e.color}
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
opacity="0.9"
/>
</svg>
) : (
<span className="champ-td-dim mono"></span>
)}
</td>
<td className="c mono" style={{ color: e.h2hWin ? 'var(--green)' : 'var(--text-2)' }}>
{e.h2h}
</td>
<td className="r">
<span className="champ-alive mono" style={{ color: e.aliveColor }}>
{e.aliveLabel}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function ConstructorsView({ hub }: { hub: ChampionshipHub }) {
const { teams, drivers } = hub
const leaderPts = teams[0]?.points ?? 0
const podium = teams.slice(0, 3)
return (
<div data-testid="champ-view-constructors">
<div className="champ-podium">
{podium.map((t) => {
const color = teamColor(t.team_colour)
const split = teamSplit(t.team_name, drivers)
return (
<div className="champ-podium-card" key={t.team_name} style={{ borderTopColor: color }}>
<span className="champ-podium-ghost mono" style={{ color: ghostColor(t.position) }}>
P{t.position}
</span>
<div className="champ-podium-inner">
<div className="champ-podium-top">
<span className="mono" style={{ color: medalColor(t.position), fontWeight: 700 }}>
P{t.position}
</span>
</div>
<div className="champ-podium-id">
<span className="champ-podium-bar" style={{ background: color }} />
<span className="champ-podium-team-name">{t.team_name}</span>
</div>
<div className="champ-podium-pts">
<span className="champ-podium-pts-num mono">{fmtPts(t.points)}</span>
<span className="champ-podium-pts-unit">PTS</span>
<span
className="champ-podium-gap mono"
style={{ color: t.position === 1 ? GOLD : 'var(--text-2)' }}
>
{t.position === 1 ? 'P1' : `+${fmtPts(leaderPts - t.points)}`}
</span>
</div>
<div className="champ-contrib">
<div className="champ-contrib-bar">
<span style={{ width: `${split.splitA}%`, background: color }} />
<span style={{ width: `${split.splitB}%`, background: color, opacity: 0.4 }} />
</div>
<div className="champ-contrib-legend mono">
<span>
{split.driverA} <em>{fmtPts(split.ptsA)}</em>
</span>
<span>
{split.driverB} <em>{fmtPts(split.ptsB)}</em>
</span>
</div>
</div>
</div>
</div>
)
})}
</div>
<div className="champ-scroll">
<table className="champ-table champ-table-teams">
<thead>
<tr>
<th className="l">Pos</th>
<th className="l">Constructor</th>
<th className="r">Pts</th>
<th className="r">Gap</th>
<th className="c">Wins</th>
<th className="l">Driver Contribution</th>
<th className="r">Share</th>
</tr>
</thead>
<tbody>
{teams.map((t) => {
const color = teamColor(t.team_colour)
const split = teamSplit(t.team_name, drivers)
return (
<tr key={t.team_name}>
<td className="mono" style={{ color: medalColor(t.position), fontWeight: 700 }}>
P{t.position}
</td>
<td>
<div className="champ-drv">
<span className="champ-drv-bar" style={{ background: color }} />
<span className="champ-team-name">{t.team_name}</span>
</div>
</td>
<td className="r mono champ-td-pts">{fmtPts(t.points)}</td>
<td className="r mono champ-td-muted">
{t.position === 1 ? 'LEADER' : `+${fmtPts(leaderPts - t.points)}`}
</td>
<td className="c mono" style={{ color: t.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{t.wins}
</td>
<td>
<div className="champ-contrib-row">
<div className="champ-contrib-bar">
<span style={{ width: `${split.splitA}%`, background: color }} />
<span style={{ width: `${split.splitB}%`, background: color, opacity: 0.4 }} />
</div>
<span className="champ-contrib-names mono">
{split.driverA}
<em> · </em>
{split.driverB}
</span>
</div>
</td>
<td className="r mono champ-td-dim">{split.shareLabel}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
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 (
<div className="champ-chart-empty" data-testid="champ-view-progression">
No completed rounds yet progression will appear after the first race.
</div>
)
}
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<string>()
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 (
<div className="champ-progression" data-testid="champ-view-progression">
<div className="champ-chart-head">
<span className="champ-chart-title mono">Cumulative points top {top.length} drivers</span>
<span className="champ-chart-meta">
Rounds 1{hub.round} · {hub.season}
</span>
</div>
<div className="champ-chart">
<svg viewBox="0 0 1000 380" className="champ-chart-svg" preserveAspectRatio="none">
{yGrid.map((g) => (
<g key={g.label}>
<line x1={CHART_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>
))}
{xGrid.map((g, i) => (
<text key={i} x={g.x} y={372} textAnchor="middle" className="champ-chart-axis">
{g.label}
</text>
))}
{series.map((s) => (
<g key={s.code}>
<polyline
points={s.points}
fill="none"
stroke={s.color}
strokeWidth={s.width}
strokeDasharray={s.dash}
strokeLinejoin="round"
strokeLinecap="round"
/>
<circle cx={s.endX} cy={s.endY} r="3" fill={s.color} />
<text x={s.endX + 8} y={s.endY + 4} fill={s.color} className="champ-chart-label">
{s.code}
</text>
</g>
))}
</svg>
</div>
<div className="champ-legend">
{series.map((s) => (
<div className="champ-legend-item" key={s.code}>
<span className="champ-legend-swatch" style={{ background: s.color }} />
<span className="champ-legend-code mono">{s.code}</span>
<span className="champ-legend-name">{s.name}</span>
<span className="champ-legend-total mono">{fmtPts(s.total)}</span>
</div>
))}
</div>
</div>
)
}