mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<RaceHub> {
|
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
||||||
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
|
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
|
||||||
@@ -52,6 +61,15 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
||||||
|
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<LiveStateResponse> {
|
export async function fetchLiveState(): Promise<LiveStateResponse> {
|
||||||
const res = await fetch('/api/v1/live/state')
|
const res = await fetch('/api/v1/live/state')
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export function Nav() {
|
|||||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||||
Race Hub
|
Race Hub
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/championship" activeProps={{ className: 'active' }}>
|
||||||
|
Championship
|
||||||
|
</Link>
|
||||||
<Link to="/briefing" activeProps={{ className: 'active' }}>
|
<Link to="/briefing" activeProps={{ className: 'active' }}>
|
||||||
Briefing
|
Briefing
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
618
frontend/src/pages/ChampionshipPage.tsx
Normal file
618
frontend/src/pages/ChampionshipPage.tsx
Normal file
@@ -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<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)}`,
|
||||||
|
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>
|
||||||
|
</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} />
|
||||||
|
)}
|
||||||
|
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||||||
|
{view === 'progression' && <ProgressionView hub={hub} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<div className="champ-drv">
|
||||||
|
<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>
|
||||||
|
</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">{e.gapAhead}</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { RaceHubPage } from './pages/RaceHubPage'
|
|||||||
import { DataLibraryPage } from './pages/DataLibraryPage'
|
import { DataLibraryPage } from './pages/DataLibraryPage'
|
||||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||||
import { BriefingPage } from './pages/BriefingPage'
|
import { BriefingPage } from './pages/BriefingPage'
|
||||||
|
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||||
|
|
||||||
type RaceHubSearch = {
|
type RaceHubSearch = {
|
||||||
session_key?: number
|
session_key?: number
|
||||||
@@ -57,6 +58,12 @@ export const liveTimingRoute = createRoute({
|
|||||||
component: LiveTimingPage,
|
component: LiveTimingPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const championshipRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/championship',
|
||||||
|
component: ChampionshipPage,
|
||||||
|
})
|
||||||
|
|
||||||
export const briefingRoute = createRoute({
|
export const briefingRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/briefing',
|
path: '/briefing',
|
||||||
@@ -69,6 +76,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
adminRoute,
|
adminRoute,
|
||||||
dataLibraryRoute,
|
dataLibraryRoute,
|
||||||
liveTimingRoute,
|
liveTimingRoute,
|
||||||
|
championshipRoute,
|
||||||
briefingRoute,
|
briefingRoute,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -3200,3 +3200,513 @@ a { color: inherit; text-decoration: none; }
|
|||||||
background: var(--surface-h);
|
background: var(--surface-h);
|
||||||
border-color: var(--border-2);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
139
frontend/src/test/ChampionshipPage.test.tsx
Normal file
139
frontend/src/test/ChampionshipPage.test.tsx
Normal file
@@ -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>): 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: () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ChampionshipPage />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: ChampionshipPage })
|
||||||
|
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||||
|
return render(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -267,6 +267,42 @@ export interface LiveStreamData {
|
|||||||
Stints: Record<string, LiveStintData[]>
|
Stints: Record<string, LiveStintData[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface NewsItem {
|
||||||
source: string
|
source: string
|
||||||
title: string
|
title: string
|
||||||
|
|||||||
@@ -597,6 +597,292 @@ func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, teams)
|
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 ---
|
// --- /api/v1/track-outline ---
|
||||||
// Accepts circuit_key and year (the frontend has both from meeting+session data).
|
// Accepts circuit_key and year (the frontend has both from meeting+session data).
|
||||||
|
|
||||||
|
|||||||
142
internal/web/championship_hub_test.go
Normal file
142
internal/web/championship_hub_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,7 @@ func (s *Server) routes() (http.Handler, error) {
|
|||||||
mux.HandleFunc("/api/v1/team-radio", s.handleTeamRadio)
|
mux.HandleFunc("/api/v1/team-radio", s.handleTeamRadio)
|
||||||
mux.HandleFunc("/api/v1/championship/drivers", s.handleChampionshipDrivers)
|
mux.HandleFunc("/api/v1/championship/drivers", s.handleChampionshipDrivers)
|
||||||
mux.HandleFunc("/api/v1/championship/teams", s.handleChampionshipTeams)
|
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/track-outline", s.handleTrackOutline)
|
||||||
mux.HandleFunc("/api/v1/strategy", s.handleStrategy)
|
mux.HandleFunc("/api/v1/strategy", s.handleStrategy)
|
||||||
mux.HandleFunc("/api/v1/live/state", s.handleLiveState)
|
mux.HandleFunc("/api/v1/live/state", s.handleLiveState)
|
||||||
|
|||||||
Reference in New Issue
Block a user