mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 20:27:03 +01:00
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>
103 lines
2.9 KiB
TypeScript
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>
|
|
)
|
|
}
|