mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Add championship what-if simulator
Fourth view on the championship page: assign P1-P10 per remaining round and see projected standings update live, with position deltas and mathematically alive/eliminated title states. Pure projection logic in lib/simulator.ts (2025 points system, standard GPs only); scenarios persist to localStorage per season. 169 frontend tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
233
frontend/src/components/ChampionshipSimulator.tsx
Normal file
233
frontend/src/components/ChampionshipSimulator.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ChampionshipHub } from '../types'
|
||||
import { teamColor } from '../utils'
|
||||
import {
|
||||
assignPosition,
|
||||
defaultRound,
|
||||
defaultScenario,
|
||||
normalizeScenario,
|
||||
pointsForPosition,
|
||||
projectStandings,
|
||||
} from '../lib/simulator'
|
||||
import type { Scenario } from '../lib/simulator'
|
||||
|
||||
const STORAGE_PREFIX = 'box-box.champ.sim'
|
||||
|
||||
function storageKey(season: number): string {
|
||||
return `${STORAGE_PREFIX}.${season}`
|
||||
}
|
||||
|
||||
function loadScenario(hub: ChampionshipHub): Scenario {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(hub.season))
|
||||
if (!raw) return defaultScenario(hub.drivers, hub.rounds_left)
|
||||
return normalizeScenario(JSON.parse(raw), hub.drivers, hub.rounds_left)
|
||||
} catch {
|
||||
return defaultScenario(hub.drivers, hub.rounds_left)
|
||||
}
|
||||
}
|
||||
|
||||
function saveScenario(season: number, scenario: Scenario) {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(season), JSON.stringify(scenario))
|
||||
} catch {
|
||||
// storage unavailable (private mode, quota) — simulator still works in memory
|
||||
}
|
||||
}
|
||||
|
||||
/** Label for the i-th remaining round (0-based), e.g. "R7" or "Round 7". */
|
||||
function roundLabel(hub: ChampionshipHub, index: number): string {
|
||||
return hub.round_labels[hub.round + index] ?? `Round ${hub.round + index + 1}`
|
||||
}
|
||||
|
||||
function fmtPts(n: number): string {
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
export function ChampionshipSimulator({ hub }: { hub: ChampionshipHub }) {
|
||||
const [scenario, setScenario] = useState<Scenario>(() => loadScenario(hub))
|
||||
const [selected, setSelected] = useState(0)
|
||||
|
||||
// Reload when the season changes (new hub, new storage key).
|
||||
useEffect(() => {
|
||||
setScenario(loadScenario(hub))
|
||||
setSelected(0)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hub.season, hub.rounds_left])
|
||||
|
||||
useEffect(() => {
|
||||
if (scenario.length > 0) saveScenario(hub.season, scenario)
|
||||
}, [hub.season, scenario])
|
||||
|
||||
const projected = useMemo(
|
||||
() => projectStandings(hub.drivers, scenario, hub.rounds_left),
|
||||
[hub.drivers, scenario, hub.rounds_left],
|
||||
)
|
||||
|
||||
if (hub.rounds_left <= 0 || hub.drivers.length === 0 || scenario.length === 0) {
|
||||
return (
|
||||
<div className="champ-chart-empty" data-testid="champ-view-simulator">
|
||||
Season complete — nothing left to simulate.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const roundIdx = Math.min(selected, scenario.length - 1)
|
||||
const round = scenario[roundIdx]
|
||||
|
||||
const setRound = (nextRound: (number | null)[]) => {
|
||||
setScenario((prev) => prev.map((r, i) => (i === roundIdx ? nextRound : r)))
|
||||
}
|
||||
|
||||
const driverByNumber = new Map(hub.drivers.map((d) => [d.driver_number, d]))
|
||||
|
||||
return (
|
||||
<div className="champ-sim" data-testid="champ-view-simulator">
|
||||
<div className="champ-sim-head">
|
||||
<span className="champ-chart-title mono">What-if simulator — remaining rounds</span>
|
||||
<button
|
||||
type="button"
|
||||
className="champ-sim-btn"
|
||||
onClick={() => setScenario(defaultScenario(hub.drivers, hub.rounds_left))}
|
||||
data-testid="sim-reset-all"
|
||||
>
|
||||
Reset all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="champ-sim-rounds" role="tablist" aria-label="Remaining rounds">
|
||||
{scenario.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`champ-sim-round${i === roundIdx ? ' is-active' : ''}`}
|
||||
onClick={() => setSelected(i)}
|
||||
data-testid={`sim-round-${i}`}
|
||||
>
|
||||
{roundLabel(hub, i)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="champ-sim-grid">
|
||||
<div className="champ-sim-editor" data-testid="sim-editor">
|
||||
<div className="champ-sim-editor-head">
|
||||
<span className="champ-sim-editor-title mono">{roundLabel(hub, roundIdx)} finishing order</span>
|
||||
<button
|
||||
type="button"
|
||||
className="champ-sim-btn"
|
||||
onClick={() => setRound(defaultRound(hub.drivers))}
|
||||
data-testid="sim-reset-round"
|
||||
>
|
||||
Reset round
|
||||
</button>
|
||||
</div>
|
||||
{round.map((driverNumber, p) => {
|
||||
const driver = driverNumber != null ? driverByNumber.get(driverNumber) : undefined
|
||||
return (
|
||||
<div className="champ-sim-slot" key={p}>
|
||||
<span className="champ-sim-slot-pos mono">P{p + 1}</span>
|
||||
<span
|
||||
className="champ-sim-slot-bar"
|
||||
style={{ background: driver ? teamColor(driver.team_colour) : 'var(--border)' }}
|
||||
/>
|
||||
<select
|
||||
className="champ-sim-select mono"
|
||||
value={driverNumber ?? ''}
|
||||
aria-label={`P${p + 1} driver`}
|
||||
data-testid={`sim-pos-${p + 1}`}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setRound(assignPosition(round, p, value === '' ? null : Number(value)))
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{hub.drivers.map((d) => (
|
||||
<option key={d.driver_number} value={d.driver_number}>
|
||||
{d.name_acronym} · {d.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="champ-sim-slot-pts mono">+{pointsForPosition(p + 1)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="champ-sim-table-wrap">
|
||||
<div className="champ-sim-editor-head">
|
||||
<span className="champ-sim-editor-title mono">Projected standings</span>
|
||||
</div>
|
||||
<div className="champ-scroll">
|
||||
<table className="champ-table champ-sim-table" data-testid="sim-projected">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="l">Pos</th>
|
||||
<th className="c">Δ</th>
|
||||
<th className="l">Driver</th>
|
||||
<th className="r">Now</th>
|
||||
<th className="r">+Sim</th>
|
||||
<th className="r">Proj</th>
|
||||
<th className="r">Title</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projected.map((row) => {
|
||||
const d = row.driver
|
||||
const moved = row.delta !== 0
|
||||
return (
|
||||
<tr
|
||||
key={d.driver_number}
|
||||
className={moved ? 'champ-sim-row-moved' : undefined}
|
||||
data-testid={`sim-row-${d.driver_number}`}
|
||||
>
|
||||
<td className="mono" style={{ fontWeight: 700 }}>
|
||||
P{row.projectedPosition}
|
||||
</td>
|
||||
<td className="c mono">
|
||||
{row.delta > 0 && (
|
||||
<span className="champ-sim-delta up">▲{row.delta}</span>
|
||||
)}
|
||||
{row.delta < 0 && (
|
||||
<span className="champ-sim-delta down">▼{-row.delta}</span>
|
||||
)}
|
||||
{row.delta === 0 && <span className="champ-td-dim">—</span>}
|
||||
</td>
|
||||
<td>
|
||||
<div className="champ-drv">
|
||||
<span
|
||||
className="champ-drv-bar"
|
||||
style={{ background: teamColor(d.team_colour) }}
|
||||
/>
|
||||
<span className="champ-drv-code mono">{d.name_acronym}</span>
|
||||
<span className="champ-drv-name">{d.full_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="r mono champ-td-muted">{fmtPts(row.currentPoints)}</td>
|
||||
<td className="r mono champ-td-dim">+{fmtPts(row.simPoints)}</td>
|
||||
<td className="r mono champ-td-pts">{fmtPts(row.projectedPoints)}</td>
|
||||
<td className="r">
|
||||
<span
|
||||
className="champ-alive mono"
|
||||
style={{ color: row.titleAlive ? 'var(--green)' : 'var(--text-3)' }}
|
||||
>
|
||||
{row.titleAlive ? 'ALIVE' : 'OUT'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="champ-sim-caption">
|
||||
Simplified model: every remaining round is scored as a standard Grand Prix
|
||||
(25-18-15-12-10-8-6-4-2-1, no fastest-lap point). Sprint weekends are ignored. Title status
|
||||
uses the max-points-remaining bound against the leader's projected total.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user