steam-backlog-enforcer/web/src/components/TimelineChart.tsx
Krzysztof kuhy Rudnicki 41deb90324 feat: add interactive web UI for backlog completion planning
Adds a React/TypeScript frontend (web/) with a Python stdlib HTTP server
backend.  The UI mirrors the CLI `stats` command in the browser, with
real-time sliders for ProtonDB rating, HLTB confidence thresholds, daily
play time, per-game time cap, playtime mode, no-HLTB-data fallback, and a
target-date planner.  A parity badge confirms the client-side totals
reproduce the CLI defaults exactly (786 / 67031.1h / 143017.2h / 238447.9h).

Python side:
- `_web_dataset.py`: offline projection of HLTB/ProtonDB/snapshot caches
  into a compact, secret-free JSON payload; 100% branch coverage
- `_web_server.py`: zero-dependency stdlib HTTP server serving the built
  Vite bundle and the /api/dataset endpoint; 100% branch coverage
- `main.py`: new `serve` command wiring

Frontend (Vitest + RTL, 100% branch coverage enforced):
- TypeScript port of ProtonDB compound rating rule with full parity
- Pure client-side filtering via estimate.ts (no server round-trips)
- SVG completion timeline chart, sortable/searchable game table
- Steam dark theme; responsive layout

Pre-commit: adds `vitest-coverage` hook at pre-push stage requiring 100%
branch coverage on the React codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:35:45 +02:00

103 lines
2.9 KiB
TypeScript

import type { EstimateResult } from '../estimate'
import { isoDate } from '../format'
import type { Filters, WebStateInfo } from '../types'
interface Props {
result: EstimateResult
filters: Filters
state: WebStateInfo
}
const W = 820
const H = 320
const PAD = { top: 16, right: 20, bottom: 36, left: 48 }
interface Point {
day: number
games: number
}
/** Build the cumulative "games finished by day N" curve for the basis. */
function buildPoints(props: Props): Point[] {
const { result, filters, state } = props
const rows = [...result.included].sort((a, b) => a.lengthHours - b.lengthHours)
const pts: Point[] = []
if (filters.basis === 'pace') {
const pace = state.pace_games_per_day
if (pace <= 0) return []
rows.forEach((_, i) => pts.push({ day: (i + 1) / pace, games: i + 1 }))
return pts
}
let cum = 0
rows.forEach((r, i) => {
cum += r.lengthHours
pts.push({ day: cum / filters.dailyHours, games: i + 1 })
})
return pts
}
function dayToDate(day: number): string {
const d = new Date()
d.setDate(d.getDate() + Math.round(day))
return isoDate(d)
}
export function TimelineChart(props: Props) {
const pts = buildPoints(props)
if (pts.length < 2) {
return (
<div className="chart">
<h2>Completion timeline</h2>
<p className="hint">Not enough games in scope to draw a timeline.</p>
</div>
)
}
const maxDay = pts[pts.length - 1].day || 1
const maxGames = pts.length
const plotW = W - PAD.left - PAD.right
const plotH = H - PAD.top - PAD.bottom
const sx = (day: number) => PAD.left + (day / maxDay) * plotW
const sy = (games: number) => PAD.top + plotH - (games / maxGames) * plotH
const path = pts
.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.day).toFixed(1)} ${sy(p.games).toFixed(1)}`)
.join(' ')
const area = `${path} L${sx(maxDay).toFixed(1)} ${sy(0).toFixed(1)} L${sx(0).toFixed(1)} ${sy(0).toFixed(1)} Z`
const xTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => f * maxDay)
const yTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => Math.round(f * maxGames))
return (
<div className="chart">
<h2>Completion timeline · {props.filters.basis}</h2>
<svg viewBox={`0 0 ${W} ${H}`} className="chart-svg" role="img">
{yTicks.map((g) => (
<g key={`y${g}`}>
<line
x1={PAD.left}
y1={sy(g)}
x2={W - PAD.right}
y2={sy(g)}
className="grid"
/>
<text x={PAD.left - 8} y={sy(g) + 4} className="axis-label end">
{g}
</text>
</g>
))}
{xTicks.map((d) => (
<text key={`x${d}`} x={sx(d)} y={H - 12} className="axis-label mid">
{dayToDate(d)}
</text>
))}
<path d={area} className="area" />
<path d={path} className="line" />
</svg>
<p className="hint">
Cumulative games finished over time (shortest games first).
</p>
</div>
)
}