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>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-29 18:35:45 +02:00
parent f845273ee7
commit 41deb90324
40 changed files with 7675 additions and 0 deletions

6
.gitignore vendored
View File

@ -20,3 +20,9 @@ coverage.xml
htmlcov/ htmlcov/
*.log *.log
.DS_Store .DS_Store
# Web UI (Vite + React frontend)
node_modules/
web/dist/
coverage/
*.tsbuildinfo

View File

@ -141,6 +141,20 @@ repos:
require_serial: true require_serial: true
stages: [pre-push] stages: [pre-push]
# ===========================================================================
# VITEST + COVERAGE (push stage)
# ===========================================================================
- repo: local
hooks:
- id: vitest-coverage
name: vitest with 100% coverage enforcement
entry: npm --prefix web run coverage
language: system
files: ^web/src/
pass_filenames: false
require_serial: true
stages: [pre-push]
# =========================================================================== # ===========================================================================
# CODESPELL - Spell checking # CODESPELL - Spell checking
# =========================================================================== # ===========================================================================

View File

@ -0,0 +1,280 @@
"""Read-only projection of cached data for the interactive web UI.
Builds a compact, secrets-free dataset from the on-disk caches (snapshot,
HLTB, ProtonDB, state) so a browser UI can filter games and estimate backlog
completion times entirely client-side. This module performs **no network
calls** it only reads caches that previous ``scan``/``stats`` runs populated.
The projection deliberately emits *every* incomplete, non-current,
non-finished game with its raw HLTB-confidence counters and ProtonDB tiers, so
the client can move its filter thresholds *below* the CLI defaults. The CLI
default thresholds and a parity summary are included so the UI can show
"matches the CLI" and so changes that break parity are easy to spot.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
from steam_backlog_enforcer._hltb_types import _read_raw_cache
from steam_backlog_enforcer._scanning_confidence import (
_MIN_COMP_100_POLLS,
_MIN_CONFIDENCE_SUM,
_MIN_COUNT_COMP,
)
from steam_backlog_enforcer.config import State, load_snapshot
from steam_backlog_enforcer.protondb import (
MIN_PLAYABLE_TIER,
ProtonDBRating,
_load_cache,
_rating_from_cache,
)
from steam_backlog_enforcer.steam_api import GameInfo
# Mirrors ``_stats._HOURS_PER_DAY_PRESETS`` but mutable/JSON-friendly.
HOURS_PER_DAY_PRESETS = [2.0, 4.0, 6.0, 8.0]
@dataclass
class WebGame:
"""One incomplete candidate game, with raw filterable fields.
Hour fields use ``-1`` to mean "no data" (matching the cache convention),
so the client can choose to include or exclude unknown-length games.
"""
app_id: int
name: str
completion_pct: float
playtime_minutes: int
rush_hours: float
leisure_hours: float
worst_hours: float
count_comp: int
comp_100_count: int
hltb_game_id: int
protondb_tier: str
protondb_trending_tier: str
protondb_score: float
@dataclass
class WebStateInfo:
"""Pace inputs and current-assignment metadata for the UI."""
current_app_id: int | None
current_game_name: str
games_done: int
days_elapsed: int
enforcement_started_at: str
pace_games_per_day: float
@dataclass
class WebDefaults:
"""The CLI's hardcoded filter thresholds, surfaced as editable defaults."""
min_comp_100_polls: int
min_count_comp: int
min_confidence_sum: int
min_playable_tier: str
hours_per_day_presets: list[float]
@dataclass
class DefaultSummary:
"""Totals the CLI ``stats`` command would print at default thresholds.
Used as a parity oracle: the client's own default-filtered totals must
reproduce these numbers.
"""
qualifying: int
rush_total: float
leisure_total: float
worst_total: float
@dataclass
class WebDataset:
"""Full payload served to the browser."""
games: list[WebGame]
state: WebStateInfo
defaults: WebDefaults
default_summary: DefaultSummary
generated_at: str = field(default="")
def _worst_hours(game: GameInfo, cache_hours: float, leisure: float) -> float:
"""Replicate ``_stats`` worst-case selection exactly.
worst = max of snapshot completionist hours, the HLTB hours-cache value,
and the leisure-100% time considering only positive values.
"""
snap_hours = game.completionist_hours if game.completionist_hours > 0 else -1
candidates = [v for v in (snap_hours, cache_hours, leisure) if v > 0]
return max(candidates) if candidates else -1.0
def _passes_default_confidence(game: WebGame) -> bool:
"""True if the game clears all three CLI HLTB-confidence thresholds."""
if game.comp_100_count < _MIN_COMP_100_POLLS:
return False
if game.count_comp < _MIN_COUNT_COMP:
return False
return game.comp_100_count + game.count_comp >= _MIN_CONFIDENCE_SUM
def _has_any_time(game: WebGame) -> bool:
"""True if the game has at least one positive time estimate."""
return game.worst_hours > 0 or game.rush_hours > 0 or game.leisure_hours > 0
def _build_games(games: list[GameInfo], exclude: set[int]) -> list[WebGame]:
"""Project incomplete, non-excluded games into compact rows (no network)."""
raw = _read_raw_cache()
protondb_cache = _load_cache()
rows: list[WebGame] = []
for game in games:
if game.is_complete or game.app_id in exclude:
continue
entry = raw.get(game.app_id, {})
rush = float(entry.get("rush_hours", -1))
leisure = float(entry.get("leisure_100h", -1))
cache_hours = float(entry.get("hours", -1))
count_comp = int(entry.get("count_comp", 0))
comp_100_count = int(entry.get("polls", 0))
hltb_game_id = int(entry.get("hltb_game_id", 0))
rating: ProtonDBRating = (
_rating_from_cache(game.app_id, protondb_cache[str(game.app_id)])
if str(game.app_id) in protondb_cache
else ProtonDBRating(app_id=game.app_id)
)
rows.append(
WebGame(
app_id=game.app_id,
name=game.name,
completion_pct=round(game.completion_pct, 1),
playtime_minutes=game.playtime_minutes,
rush_hours=rush,
leisure_hours=leisure,
worst_hours=_worst_hours(game, cache_hours, leisure),
count_comp=count_comp,
comp_100_count=comp_100_count,
hltb_game_id=hltb_game_id,
protondb_tier=rating.tier,
protondb_trending_tier=rating.trending_tier,
protondb_score=rating.score,
)
)
return rows
def _default_qualifying(rows: list[WebGame]) -> list[WebGame]:
"""Apply the exact CLI default filters (confidence + ProtonDB + has-data)."""
qualifying: list[WebGame] = []
for game in rows:
if not _passes_default_confidence(game):
continue
rating = ProtonDBRating(
app_id=game.app_id,
tier=game.protondb_tier,
trending_tier=game.protondb_trending_tier,
)
if not rating.is_playable:
continue
if not _has_any_time(game):
continue
qualifying.append(game)
return qualifying
def _sum_positive(rows: list[WebGame], attr: str) -> float:
"""Sum a positive-only hour attribute across rows (matches ``_sum_hours``)."""
total = sum(getattr(g, attr) for g in rows if getattr(g, attr) > 0)
return round(total, 1)
def _default_summary(rows: list[WebGame]) -> DefaultSummary:
"""Compute the CLI parity totals at default thresholds."""
qualifying = _default_qualifying(rows)
return DefaultSummary(
qualifying=len(qualifying),
rush_total=_sum_positive(qualifying, "rush_hours"),
leisure_total=_sum_positive(qualifying, "leisure_hours"),
worst_total=_sum_positive(qualifying, "worst_hours"),
)
def _state_info(state: State, games_done: int) -> WebStateInfo:
"""Build pace metadata, mirroring ``_print_pace_scenario`` inputs."""
days_elapsed = 0
pace = 0.0
if state.enforcement_started_at:
try:
started = datetime.fromisoformat(state.enforcement_started_at)
except ValueError:
started = None
if started is not None:
now = datetime.now(timezone.utc)
days_elapsed = max(1, (now - started).days)
if games_done > 0:
pace = round(games_done / days_elapsed, 4)
return WebStateInfo(
current_app_id=state.current_app_id,
current_game_name=state.current_game_name,
games_done=games_done,
days_elapsed=days_elapsed,
enforcement_started_at=state.enforcement_started_at,
pace_games_per_day=pace,
)
def build_web_dataset(state: State) -> WebDataset:
"""Build the full web dataset from on-disk caches (no network calls).
Args:
state: The loaded enforcer state (current game, finished IDs, pace).
Returns:
A ``WebDataset`` with every incomplete candidate game, the CLI default
thresholds, and a parity summary. Raises no exceptions for a missing
snapshot it returns an empty game list instead.
"""
snapshot = load_snapshot()
raw_games = (
[GameInfo.from_snapshot(d) for d in snapshot] if snapshot is not None else []
)
games_done = sum(1 for g in raw_games if g.is_complete)
exclude = set(state.finished_app_ids)
if state.current_app_id is not None:
exclude.add(state.current_app_id)
rows = _build_games(raw_games, exclude)
return WebDataset(
games=rows,
state=_state_info(state, games_done),
defaults=WebDefaults(
min_comp_100_polls=_MIN_COMP_100_POLLS,
min_count_comp=_MIN_COUNT_COMP,
min_confidence_sum=_MIN_CONFIDENCE_SUM,
min_playable_tier=MIN_PLAYABLE_TIER,
hours_per_day_presets=list(HOURS_PER_DAY_PRESETS),
),
default_summary=_default_summary(rows),
generated_at=datetime.now(timezone.utc).isoformat(),
)
def dataset_to_payload(dataset: WebDataset) -> dict[str, Any]:
"""Serialize a ``WebDataset`` to a JSON-ready dict."""
return asdict(dataset)

View File

@ -0,0 +1,109 @@
"""Minimal read-only localhost HTTP server for the interactive web UI.
Serves the projected dataset at ``GET /api/dataset`` and the built React
bundle (``web/dist``) as static files. Binds to localhost only and never
exposes secrets: the payload comes from :func:`build_web_dataset`, which reads
the data caches but never ``config.json``.
In development the Vite dev server proxies ``/api`` here; in production the
``serve`` command serves the built bundle and the API from one process.
"""
from __future__ import annotations
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import logging
import mimetypes
from pathlib import Path
from urllib.parse import urlsplit
from steam_backlog_enforcer._web_dataset import build_web_dataset, dataset_to_payload
from steam_backlog_enforcer.config import State
from steam_backlog_enforcer.game_install import _echo
logger = logging.getLogger(__name__)
# Built frontend lives at <repo>/web/dist (sibling of the package directory).
WEB_DIST = (Path(__file__).resolve().parent.parent / "web" / "dist").resolve()
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8000
_API_DATASET = "/api/dataset"
# Content types that are text but not under the ``text/`` prefix.
_EXTRA_TEXT_TYPES = frozenset(
{"application/javascript", "application/json", "image/svg+xml"}
)
_NOT_BUILT_MSG = b"Frontend not built. Run: cd web && npm install && npm run build"
class _Handler(BaseHTTPRequestHandler):
"""Serve the dataset JSON and the static frontend bundle (read-only)."""
def log_message(self, fmt: str, *args: object) -> None:
"""Route the default request log to ``logging`` at debug level."""
logger.debug("%s - %s", self.address_string(), fmt % args)
def do_GET(self) -> None:
"""Dispatch a GET to the dataset API or to a static file."""
path = urlsplit(self.path).path
if path == _API_DATASET:
self._serve_dataset()
else:
self._serve_static(path)
def _serve_dataset(self) -> None:
"""Build and send the projected dataset as JSON."""
try:
payload = dataset_to_payload(build_web_dataset(State.load()))
body = json.dumps(payload).encode("utf-8")
except (OSError, ValueError, KeyError):
logger.exception("Failed to build web dataset")
self._send(HTTPStatus.INTERNAL_SERVER_ERROR, b"dataset error", "text/plain")
return
self._send(HTTPStatus.OK, body, "application/json")
def _serve_static(self, path: str) -> None:
"""Serve a file from ``WEB_DIST`` with SPA fallback and traversal guard."""
rel = path.lstrip("/") or "index.html"
candidate = (WEB_DIST / rel).resolve()
# Reject path traversal, then fall back to index.html for SPA routes.
if not candidate.is_relative_to(WEB_DIST) or not candidate.is_file():
candidate = WEB_DIST / "index.html"
if not candidate.is_file():
self._send(HTTPStatus.NOT_FOUND, _NOT_BUILT_MSG, "text/plain")
return
ctype, _ = mimetypes.guess_type(candidate.name)
self._send(HTTPStatus.OK, candidate.read_bytes(), ctype or "text/plain")
def _send(self, status: HTTPStatus, body: bytes, ctype: str) -> None:
"""Write a complete response with the given status, body, and type."""
if ctype.startswith("text/") or ctype in _EXTRA_TEXT_TYPES:
ctype = f"{ctype}; charset=utf-8"
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def create_server(
host: str = DEFAULT_HOST, port: int = DEFAULT_PORT
) -> ThreadingHTTPServer:
"""Create (but do not start) the threading HTTP server."""
return ThreadingHTTPServer((host, port), _Handler)
def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
"""Run the web server until interrupted with Ctrl-C."""
server = create_server(host, port)
_echo(f"Steam Backlog Enforcer web UI: http://{host}:{port}")
_echo("Press Ctrl-C to stop.")
try:
server.serve_forever()
except KeyboardInterrupt:
_echo("\nShutting down.")
finally:
server.server_close()

View File

@ -14,6 +14,7 @@ from steam_backlog_enforcer._enforce_loop import (
) )
from steam_backlog_enforcer._hltb_types import load_hltb_cache from steam_backlog_enforcer._hltb_types import load_hltb_cache
from steam_backlog_enforcer._stats import cmd_stats from steam_backlog_enforcer._stats import cmd_stats
from steam_backlog_enforcer._web_server import serve
from steam_backlog_enforcer._whitelist import ( from steam_backlog_enforcer._whitelist import (
WHITELIST_COOLDOWN_SECONDS, WHITELIST_COOLDOWN_SECONDS,
add_pending_exception, add_pending_exception,
@ -381,6 +382,11 @@ def cmd_pick(config: Config, state: State) -> None:
_echo(f"\n Library: hid {hidden} games") _echo(f"\n Library: hid {hidden} games")
def cmd_serve(_config: Config, _state: State) -> None:
"""Start the interactive web UI server (read-only, localhost only)."""
serve()
COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = { COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = {
"scan": ("Scan library & assign a game", do_scan), "scan": ("Scan library & assign a game", do_scan),
"check": ("Check assigned game completion", do_check), "check": ("Check assigned game completion", do_check),
@ -399,6 +405,7 @@ COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = {
"done": ("Finish game, open HLTB, pick next", cmd_done), "done": ("Finish game, open HLTB, pick next", cmd_done),
"pick": ("Manually pick your next game from candidates", cmd_pick), "pick": ("Manually pick your next game from candidates", cmd_pick),
"stats": ("Show backlog completion-time estimates", cmd_stats), "stats": ("Show backlog completion-time estimates", cmd_stats),
"serve": ("Start the interactive web UI (browser) server", cmd_serve),
} }
# Extra commands with non-standard arg handling (shown in help but not in COMMANDS). # Extra commands with non-standard arg handling (shown in help but not in COMMANDS).

View File

@ -0,0 +1,330 @@
"""Tests for _web_dataset module — 100% branch coverage."""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from steam_backlog_enforcer._web_dataset import (
HOURS_PER_DAY_PRESETS,
WebGame,
_build_games,
_default_qualifying,
_default_summary,
_has_any_time,
_passes_default_confidence,
_state_info,
_sum_positive,
_worst_hours,
build_web_dataset,
dataset_to_payload,
)
from steam_backlog_enforcer.config import State
from steam_backlog_enforcer.steam_api import GameInfo
_PKG = "steam_backlog_enforcer._web_dataset"
def _gi(**over: object) -> GameInfo:
"""Build a GameInfo with field overrides."""
base = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=0,
playtime_minutes=60,
completionist_hours=20.0,
comp_100_count=5,
count_comp=20,
)
return replace(base, **over)
def _wg(**over: object) -> WebGame:
"""Build a WebGame with field overrides."""
base = WebGame(
app_id=1,
name="Game1",
completion_pct=0.0,
playtime_minutes=60,
rush_hours=10.0,
leisure_hours=20.0,
worst_hours=25.0,
count_comp=20,
comp_100_count=5,
hltb_game_id=0,
protondb_tier="gold",
protondb_trending_tier="gold",
protondb_score=0.8,
)
return replace(base, **over)
class TestWorstHours:
"""Tests for _worst_hours (mirrors _stats worst-case selection)."""
def test_completionist_dominates(self) -> None:
game = _gi(completionist_hours=30.0)
assert _worst_hours(game, cache_hours=10.0, leisure=20.0) == 30.0
def test_falls_back_to_cache_and_leisure_when_completionist_zero(self) -> None:
game = _gi(completionist_hours=0.0)
assert _worst_hours(game, cache_hours=15.0, leisure=8.0) == 15.0
def test_minus_one_when_all_non_positive(self) -> None:
game = _gi(completionist_hours=0.0)
assert _worst_hours(game, cache_hours=-1.0, leisure=-1.0) == -1.0
class TestPassesDefaultConfidence:
"""Tests for _passes_default_confidence."""
def test_fail_low_comp_100(self) -> None:
assert _passes_default_confidence(_wg(comp_100_count=2)) is False
def test_fail_low_count_comp(self) -> None:
assert _passes_default_confidence(_wg(comp_100_count=5, count_comp=10)) is False
def test_pass_when_all_thresholds_met(self) -> None:
assert _passes_default_confidence(_wg(comp_100_count=5, count_comp=20)) is True
class TestHasAnyTime:
"""Tests for _has_any_time."""
def test_true_when_some_positive(self) -> None:
assert _has_any_time(_wg(rush_hours=-1, leisure_hours=-1, worst_hours=5.0))
def test_false_when_all_non_positive(self) -> None:
game = _wg(rush_hours=-1, leisure_hours=-1, worst_hours=-1)
assert _has_any_time(game) is False
class TestDefaultQualifying:
"""Tests for _default_qualifying — each filter rejection branch."""
def test_rejects_low_confidence(self) -> None:
assert _default_qualifying([_wg(count_comp=0)]) == []
def test_rejects_unplayable(self) -> None:
game = _wg(protondb_tier="borked", protondb_trending_tier="borked")
assert _default_qualifying([game]) == []
def test_rejects_no_time(self) -> None:
game = _wg(rush_hours=-1, leisure_hours=-1, worst_hours=-1)
assert _default_qualifying([game]) == []
def test_accepts_qualifying_game(self) -> None:
assert len(_default_qualifying([_wg()])) == 1
class TestSumPositive:
"""Tests for _sum_positive."""
def test_sums_only_positive(self) -> None:
rows = [_wg(rush_hours=10.0), _wg(rush_hours=-1.0), _wg(rush_hours=5.5)]
assert _sum_positive(rows, "rush_hours") == 15.5
def test_empty(self) -> None:
assert _sum_positive([], "rush_hours") == 0.0
class TestDefaultSummary:
"""Tests for _default_summary."""
def test_totals(self) -> None:
rows = [_wg(rush_hours=10.0, leisure_hours=20.0, worst_hours=25.0)]
summary = _default_summary(rows)
assert summary.qualifying == 1
assert summary.rush_total == 10.0
assert summary.leisure_total == 20.0
assert summary.worst_total == 25.0
class TestStateInfo:
"""Tests for _state_info pace calculation."""
def test_no_start_date(self) -> None:
info = _state_info(State(), games_done=5)
assert info.days_elapsed == 0
assert info.pace_games_per_day == 0.0
def test_invalid_start_date(self) -> None:
info = _state_info(State(enforcement_started_at="not-a-date"), games_done=5)
assert info.days_elapsed == 0
assert info.pace_games_per_day == 0.0
def test_valid_start_with_games(self) -> None:
started = datetime.now(timezone.utc) - timedelta(days=50)
info = _state_info(
State(enforcement_started_at=started.isoformat()), games_done=10
)
assert info.days_elapsed >= 49
assert info.pace_games_per_day > 0.0
def test_valid_start_zero_games_keeps_zero_pace(self) -> None:
started = datetime.now(timezone.utc) - timedelta(days=50)
info = _state_info(
State(enforcement_started_at=started.isoformat()), games_done=0
)
assert info.days_elapsed >= 49
assert info.pace_games_per_day == 0.0
class TestBuildGames:
"""Tests for _build_games (patches cache loaders, no file I/O)."""
def _run(
self,
games: list[GameInfo],
exclude: set[int],
raw: dict[int, dict[str, object]] | None = None,
protondb: dict[str, dict[str, object]] | None = None,
) -> list[WebGame]:
with (
patch(f"{_PKG}._read_raw_cache", return_value=raw or {}),
patch(f"{_PKG}._load_cache", return_value=protondb or {}),
):
return _build_games(games, exclude)
def test_skips_complete_games(self) -> None:
rows = self._run(
[_gi(app_id=1, total_achievements=5, unlocked_achievements=5)], set()
)
assert rows == []
def test_skips_excluded_games(self) -> None:
assert self._run([_gi(app_id=1)], {1}) == []
def test_uses_cache_entry_when_present(self) -> None:
raw = {
1: {
"hours": 18.0,
"polls": 7,
"count_comp": 30,
"rush_hours": 9.0,
"leisure_100h": 22.0,
"hltb_game_id": 555,
}
}
proton = {"1": {"tier": "platinum", "trending_tier": "gold", "score": 0.9}}
rows = self._run([_gi(app_id=1, completionist_hours=0.0)], set(), raw, proton)
assert len(rows) == 1
row = rows[0]
assert row.rush_hours == 9.0
assert row.leisure_hours == 22.0
assert row.worst_hours == 22.0 # max(cache 18, leisure 22)
assert row.count_comp == 30
assert row.comp_100_count == 7
assert row.hltb_game_id == 555
assert row.protondb_tier == "platinum"
assert row.protondb_trending_tier == "gold"
def test_defaults_when_no_cache_entries(self) -> None:
rows = self._run([_gi(app_id=1, completionist_hours=12.0)], set())
assert len(rows) == 1
row = rows[0]
assert row.rush_hours == -1
assert row.leisure_hours == -1
assert row.worst_hours == 12.0 # completionist only
assert row.protondb_tier == "" # no protondb entry
class TestBuildWebDataset:
"""Tests for build_web_dataset (top-level projection)."""
def test_no_snapshot_returns_empty_games(self) -> None:
with (
patch(f"{_PKG}.load_snapshot", return_value=None),
patch(f"{_PKG}._read_raw_cache", return_value={}),
patch(f"{_PKG}._load_cache", return_value={}),
):
ds = build_web_dataset(State())
assert ds.games == []
assert ds.state.games_done == 0
assert ds.default_summary.qualifying == 0
assert ds.defaults.hours_per_day_presets == list(HOURS_PER_DAY_PRESETS)
def test_excludes_current_app_id(self) -> None:
snapshot = [_gi(app_id=1).to_snapshot(), _gi(app_id=2).to_snapshot()]
raw = {
aid: {
"hours": -1,
"polls": 5,
"count_comp": 20,
"rush_hours": 10.0,
"leisure_100h": 25.0,
"hltb_game_id": 0,
}
for aid in (1, 2)
}
proton = {str(a): {"tier": "gold", "trending_tier": "gold"} for a in (1, 2)}
with (
patch(f"{_PKG}.load_snapshot", return_value=snapshot),
patch(f"{_PKG}._read_raw_cache", return_value=raw),
patch(f"{_PKG}._load_cache", return_value=proton),
):
ds = build_web_dataset(State(current_app_id=1))
assert [g.app_id for g in ds.games] == [2]
def test_parity_mini_oracle(self) -> None:
"""A small hand-checked dataset reproduces qualifying + totals."""
# g1 qualifies; g2 fails confidence; g3 is complete (excluded).
snapshot = [
_gi(app_id=1, completionist_hours=0.0).to_snapshot(),
_gi(app_id=2, completionist_hours=0.0).to_snapshot(),
_gi(app_id=3, total_achievements=5, unlocked_achievements=5).to_snapshot(),
]
raw = {
1: {
"hours": -1,
"polls": 5,
"count_comp": 20,
"rush_hours": 10.0,
"leisure_100h": 25.0,
"hltb_game_id": 0,
},
2: {
"hours": -1,
"polls": 5,
"count_comp": 0, # fails count_comp threshold
"rush_hours": 10.0,
"leisure_100h": 25.0,
"hltb_game_id": 0,
},
}
proton = {"1": {"tier": "gold", "trending_tier": "gold"}}
with (
patch(f"{_PKG}.load_snapshot", return_value=snapshot),
patch(f"{_PKG}._read_raw_cache", return_value=raw),
patch(f"{_PKG}._load_cache", return_value=proton),
):
ds = build_web_dataset(State())
assert ds.state.games_done == 1 # g3 complete
assert len(ds.games) == 2 # g1 + g2 candidates, g3 excluded
assert ds.default_summary.qualifying == 1 # only g1
assert ds.default_summary.rush_total == 10.0
assert ds.default_summary.leisure_total == 25.0
assert ds.default_summary.worst_total == 25.0
class TestDatasetToPayload:
"""Tests for dataset_to_payload."""
def test_serializes_to_dict(self) -> None:
with (
patch(f"{_PKG}.load_snapshot", return_value=None),
patch(f"{_PKG}._read_raw_cache", return_value={}),
patch(f"{_PKG}._load_cache", return_value={}),
):
payload = dataset_to_payload(build_web_dataset(State()))
assert set(payload) == {
"games",
"state",
"defaults",
"default_summary",
"generated_at",
}
assert isinstance(payload["games"], list)
assert isinstance(payload["state"], dict)

View File

@ -0,0 +1,181 @@
"""Tests for _web_server module — 100% branch coverage."""
from __future__ import annotations
from contextlib import contextmanager
from http.client import HTTPConnection
import json
import threading
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer import main as main_mod
from steam_backlog_enforcer._web_server import create_server, serve
from steam_backlog_enforcer.config import Config, State
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_PKG = "steam_backlog_enforcer._web_server"
_DATA_PKG = "steam_backlog_enforcer._web_dataset"
@contextmanager
def _running() -> Iterator[int]:
"""Start the server on an ephemeral port in a thread; yield the port."""
server = create_server("127.0.0.1", 0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server.server_address[1]
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
def _get(port: int, path: str) -> tuple[int, bytes, str]:
"""Make a GET request, returning (status, body, content-type)."""
conn = HTTPConnection("127.0.0.1", port, timeout=5)
try:
conn.request("GET", path)
resp = conn.getresponse()
return resp.status, resp.read(), resp.headers.get("Content-Type", "")
finally:
conn.close()
def _make_dist(
tmp_path: Path,
*,
with_index: bool = True,
files: dict[str, bytes] | None = None,
) -> Path:
"""Create a fake built-frontend directory."""
dist = (tmp_path / "dist").resolve()
dist.mkdir()
if with_index:
(dist / "index.html").write_text("<html>INDEX</html>", encoding="utf-8")
for name, content in (files or {}).items():
(dist / name).write_bytes(content)
return dist
class TestDatasetEndpoint:
"""Tests for the /api/dataset route."""
def test_dataset_ok(self) -> None:
with (
patch(f"{_DATA_PKG}.load_snapshot", return_value=None),
patch(f"{_DATA_PKG}._read_raw_cache", return_value={}),
patch(f"{_DATA_PKG}._load_cache", return_value={}),
_running() as port,
):
status, body, ctype = _get(port, "/api/dataset")
assert status == 200
assert "application/json" in ctype
assert "charset=utf-8" in ctype
assert "games" in json.loads(body)
def test_dataset_error_returns_500(self) -> None:
with (
patch(f"{_PKG}.build_web_dataset", side_effect=OSError("boom")),
_running() as port,
):
status, body, _ = _get(port, "/api/dataset")
assert status == 500
assert b"dataset error" in body
class TestStaticServing:
"""Tests for static-file serving + SPA fallback + traversal guard."""
def test_serves_index(self, tmp_path: Path) -> None:
dist = _make_dist(tmp_path)
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
status, body, ctype = _get(port, "/")
assert status == 200
assert b"INDEX" in body
assert "text/html" in ctype
def test_serves_js_with_charset(self, tmp_path: Path) -> None:
dist = _make_dist(tmp_path, files={"app.js": b"console.log(1)"})
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
status, _, ctype = _get(port, "/app.js")
assert status == 200
assert "charset=utf-8" in ctype
def test_serves_binary_without_charset(self, tmp_path: Path) -> None:
dist = _make_dist(tmp_path, files={"pic.png": b"\x89PNG\r\n"})
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
status, _, ctype = _get(port, "/pic.png")
assert status == 200
assert "image/png" in ctype
assert "charset" not in ctype
def test_spa_fallback_to_index(self, tmp_path: Path) -> None:
dist = _make_dist(tmp_path)
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
status, body, _ = _get(port, "/some/spa/route")
assert status == 200
assert b"INDEX" in body
def test_path_traversal_blocked(self, tmp_path: Path) -> None:
dist = _make_dist(tmp_path)
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
status, body, _ = _get(port, "/../../../../../../etc/passwd")
assert status == 200
assert b"INDEX" in body # fell back to index, did not serve the secret
assert b"root:" not in body
def test_not_built_returns_404(self, tmp_path: Path) -> None:
dist = _make_dist(tmp_path, with_index=False)
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
status, body, _ = _get(port, "/")
assert status == 404
assert b"not built" in body.lower()
class TestCreateServer:
"""Tests for create_server."""
def test_binds_localhost(self) -> None:
server = create_server("127.0.0.1", 0)
try:
assert server.server_address[0] == "127.0.0.1"
finally:
server.server_close()
class TestServe:
"""Tests for the blocking serve() entry point."""
def test_keyboard_interrupt_shuts_down(self) -> None:
fake = MagicMock()
fake.serve_forever.side_effect = KeyboardInterrupt
with (
patch(f"{_PKG}.create_server", return_value=fake),
patch(f"{_PKG}._echo"),
):
serve()
fake.serve_forever.assert_called_once()
fake.server_close.assert_called_once()
def test_normal_return_closes_server(self) -> None:
fake = MagicMock()
with (
patch(f"{_PKG}.create_server", return_value=fake),
patch(f"{_PKG}._echo"),
):
serve()
fake.server_close.assert_called_once()
class TestCmdServe:
"""Tests for the main.cmd_serve wiring."""
def test_invokes_serve(self) -> None:
with patch.object(main_mod, "serve") as mock_serve:
main_mod.cmd_serve(Config(), State())
mock_serve.assert_called_once()

24
web/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
web/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

22
web/eslint.config.js Normal file
View File

@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'coverage']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

13
web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Steam Backlog Enforcer — Completion Planner</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4051
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
web/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.7",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.7"
}
}

1
web/public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

96
web/src/App.test.tsx Normal file
View File

@ -0,0 +1,96 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App'
import { makeDataset, makeGame, makeState } from './test/factories'
describe('App', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders the planner after data loads', async () => {
const ds = makeDataset([makeGame({ app_id: 1, name: 'Alpha' })])
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: async () => ds }),
)
render(<App />)
expect(screen.getByText(/Loading your backlog/i)).toBeInTheDocument()
await waitFor(() =>
expect(
screen.getByRole('heading', { name: 'Backlog Completion Planner' }),
).toBeInTheDocument(),
)
expect(screen.getByText(/CLI default qualifies/i)).toBeInTheDocument()
})
it('shows an error when the API call fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' }),
)
render(<App />)
await waitFor(() =>
expect(screen.getByText(/Could not load data/i)).toBeInTheDocument(),
)
})
it('handles a non-Error rejection', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue('network down'))
render(<App />)
await waitFor(() =>
expect(screen.getByText(/network down/i)).toBeInTheDocument(),
)
})
it('recomputes scope when basis changes and a game is excluded', async () => {
const ds = makeDataset(
[makeGame({ app_id: 1, name: 'Alpha' }), makeGame({ app_id: 2, name: 'Beta' })],
{
state: makeState({
current_game_name: 'Hollow Knight',
enforcement_started_at: '2026-03-04T00:00:00+00:00',
pace_games_per_day: 0.9,
}),
},
)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: async () => ds }),
)
const user = userEvent.setup()
render(<App />)
await waitFor(() =>
expect(
screen.getByRole('heading', { name: 'Backlog Completion Planner' }),
).toBeInTheDocument(),
)
// The current game appears in the header (covers the conditional branch).
expect(screen.getByText(/Hollow Knight/)).toBeInTheDocument()
expect(document.querySelector('.big')?.textContent).toBe('2')
// Switching basis promotes the Rush card to active.
await user.click(screen.getByRole('button', { name: 'Rush' }))
expect(document.querySelector('.card.active .card-title')?.textContent).toBe('Rush')
// Excluding a game drops the in-scope count.
await user.click(within(screen.getByRole('table')).getAllByRole('checkbox')[0])
expect(document.querySelector('.big')?.textContent).toBe('1')
// Re-including it restores the count (covers the toggle-off branch).
await user.click(within(screen.getByRole('table')).getAllByRole('checkbox')[0])
expect(document.querySelector('.big')?.textContent).toBe('2')
// Searching narrows the table (covers the search handler).
await user.type(screen.getByPlaceholderText(/Search games/i), 'Alpha')
expect(within(screen.getByRole('table')).queryByText('Beta')).toBeNull()
// Reset restores the full scope (covers the reset handler).
await user.click(screen.getByRole('button', { name: /Reset to CLI defaults/i }))
expect(document.querySelector('.big')?.textContent).toBe('2')
})
})

129
web/src/App.tsx Normal file
View File

@ -0,0 +1,129 @@
import { useEffect, useMemo, useState } from 'react'
import { fetchDataset } from './api'
import { FilterPanel } from './components/FilterPanel'
import { GameTable } from './components/GameTable'
import { SummaryCards } from './components/SummaryCards'
import { TimelineChart } from './components/TimelineChart'
import { applyFilters } from './estimate'
import type { Filters, WebDataset, WebDefaults } from './types'
function defaultFilters(d: WebDefaults): Filters {
return {
minCountComp: d.min_count_comp,
minComp100: d.min_comp_100_polls,
minConfidenceSum: d.min_confidence_sum,
protonMode: 'playable',
protonMinTier: d.min_playable_tier,
protonTreatMissingAsPass: true,
dailyHours: 4,
basis: 'leisure',
maxHoursPerGame: 0,
playtimeMode: 'all',
includeNoData: false,
fallbackHours: 20,
excluded: new Set<number>(),
search: '',
targetDate: '',
}
}
function App() {
const [dataset, setDataset] = useState<WebDataset | null>(null)
const [filters, setFilters] = useState<Filters | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchDataset()
.then((d) => {
setDataset(d)
setFilters(defaultFilters(d.defaults))
})
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
}, [])
const result = useMemo(
() => (dataset && filters ? applyFilters(dataset, filters) : null),
[dataset, filters],
)
if (error) {
return (
<div className="status">
<h1>Steam Backlog Enforcer</h1>
<p className="error">Could not load data: {error}</p>
<p className="hint">
Is the backend running? Start it with <code>./run.sh serve</code>.
</p>
</div>
)
}
if (!dataset || !filters || !result) {
return (
<div className="status">
<h1>Steam Backlog Enforcer</h1>
<p className="hint">Loading your backlog</p>
</div>
)
}
const update = (patch: Partial<Filters>) => setFilters({ ...filters, ...patch })
const toggleExclude = (appId: number) => {
const next = new Set(filters.excluded)
if (next.has(appId)) next.delete(appId)
else next.add(appId)
setFilters({ ...filters, excluded: next })
}
const tableRows = result.rows.filter((r) => r.passesFilters)
return (
<div className="app">
<header className="app-head">
<div>
<h1>Backlog Completion Planner</h1>
<p className="sub">
{dataset.state.current_game_name && (
<>
Currently playing <strong>{dataset.state.current_game_name}</strong>{' '}
·{' '}
</>
)}
{dataset.state.games_done} games finished since{' '}
{dataset.state.enforcement_started_at.slice(0, 10) || '—'} ·{' '}
{dataset.games.length} candidates
</p>
</div>
</header>
<div className="layout">
<FilterPanel
filters={filters}
defaults={dataset.defaults}
update={update}
onReset={() => setFilters(defaultFilters(dataset.defaults))}
/>
<main className="content">
<SummaryCards
result={result}
filters={filters}
state={dataset.state}
presets={dataset.defaults.hours_per_day_presets}
defaultQualifying={dataset.default_summary.qualifying}
/>
<TimelineChart result={result} filters={filters} state={dataset.state} />
<GameTable
rows={tableRows}
search={filters.search}
onSearch={(s) => update({ search: s })}
onToggleExclude={toggleExclude}
/>
</main>
</div>
</div>
)
}
export default App

10
web/src/api.ts Normal file
View File

@ -0,0 +1,10 @@
import type { WebDataset } from './types'
/** Fetch the projected dataset from the Python backend. */
export async function fetchDataset(): Promise<WebDataset> {
const resp = await fetch('/api/dataset')
if (!resp.ok) {
throw new Error(`API returned ${resp.status} ${resp.statusText}`)
}
return (await resp.json()) as WebDataset
}

View File

@ -0,0 +1,122 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { Filters } from '../types'
import { makeDataset, makeFilters } from '../test/factories'
import { FilterPanel } from './FilterPanel'
function setup(over: Partial<Filters> = {}) {
const update = vi.fn()
const onReset = vi.fn()
render(
<FilterPanel
filters={makeFilters(over)}
defaults={makeDataset().defaults}
update={update}
onReset={onReset}
/>,
)
return { update, onReset, user: userEvent.setup() }
}
describe('FilterPanel', () => {
it('renders the Filters heading', () => {
setup()
expect(screen.getByRole('heading', { name: 'Filters' })).toBeInTheDocument()
})
it('switches the estimate basis when a segment is clicked', async () => {
const { update, user } = setup()
await user.click(screen.getByRole('button', { name: 'Rush' }))
expect(update).toHaveBeenCalledWith({ basis: 'rush' })
})
it('switches ProtonDB to min-tier mode and reveals the tier select', async () => {
const { update, user } = setup()
await user.click(screen.getByRole('button', { name: 'Min tier' }))
expect(update).toHaveBeenCalledWith({ protonMode: 'minTier' })
})
it('shows the tier dropdown when already in min-tier mode', () => {
setup({ protonMode: 'minTier' })
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('switches ProtonDB back to playable mode', async () => {
const { update, user } = setup({ protonMode: 'minTier' })
await user.click(screen.getByRole('button', { name: /Playable \(CLI rule\)/i }))
expect(update).toHaveBeenCalledWith({ protonMode: 'playable' })
})
it('toggles the include-no-data option', async () => {
const { update, user } = setup()
await user.click(screen.getByLabelText(/Include games with no HLTB data/i))
expect(update).toHaveBeenCalledWith({ includeNoData: true })
})
it('reveals the fallback slider when no-data games are included', () => {
setup({ includeNoData: true })
expect(screen.getByText(/Fallback estimate/i)).toBeInTheDocument()
})
it('calls onReset when the reset button is clicked', async () => {
const { onReset, user } = setup()
await user.click(screen.getByRole('button', { name: /Reset to CLI defaults/i }))
expect(onReset).toHaveBeenCalledTimes(1)
})
it('updates daily hours and min completions via sliders', () => {
const { update } = setup()
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[0], { target: { value: '8' } })
expect(update).toHaveBeenCalledWith({ dailyHours: 8 })
fireEvent.change(sliders[1], { target: { value: '40' } })
expect(update).toHaveBeenCalledWith({ minCountComp: 40 })
})
it('updates advanced confidence and the max-hours cap', () => {
const { update } = setup()
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[2], { target: { value: '10' } })
expect(update).toHaveBeenCalledWith({ minComp100: 10 })
fireEvent.change(sliders[3], { target: { value: '25' } })
expect(update).toHaveBeenCalledWith({ minConfidenceSum: 25 })
fireEvent.change(sliders[4], { target: { value: '50' } })
expect(update).toHaveBeenCalledWith({ maxHoursPerGame: 50 })
})
it('switches the playtime mode', async () => {
const { update, user } = setup()
await user.click(screen.getByRole('button', { name: 'Started' }))
expect(update).toHaveBeenCalledWith({ playtimeMode: 'started' })
})
it('updates and clears the target date', async () => {
const { update, user } = setup({ targetDate: '2030-01-01' })
const date = document.querySelector('input[type=date]')
fireEvent.change(date as Element, { target: { value: '2031-02-03' } })
expect(update).toHaveBeenCalledWith({ targetDate: '2031-02-03' })
await user.click(screen.getByRole('button', { name: 'Clear' }))
expect(update).toHaveBeenCalledWith({ targetDate: '' })
})
it('updates the min tier and treat-missing toggle in min-tier mode', async () => {
const { update, user } = setup({ protonMode: 'minTier' })
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'silver' } })
expect(update).toHaveBeenCalledWith({ protonMinTier: 'silver' })
await user.click(screen.getByLabelText(/Keep games with no ProtonDB data/i))
expect(update).toHaveBeenCalledWith({ protonTreatMissingAsPass: false })
})
it('updates the fallback estimate when no-data games are included', () => {
const { update } = setup({ includeNoData: true })
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[sliders.length - 1], { target: { value: '30' } })
expect(update).toHaveBeenCalledWith({ fallbackHours: 30 })
})
it('shows the hours-cap value when maxHoursPerGame is set', () => {
setup({ maxHoursPerGame: 50 })
expect(screen.getByText('50 h')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,241 @@
import { SELECTABLE_TIERS } from '../protondb'
import type {
EstimateBasis,
Filters,
PlaytimeMode,
WebDefaults,
} from '../types'
interface Props {
filters: Filters
defaults: WebDefaults
update: (patch: Partial<Filters>) => void
onReset: () => void
}
const BASES: { id: EstimateBasis; label: string }[] = [
{ id: 'rush', label: 'Rush' },
{ id: 'leisure', label: 'Leisure' },
{ id: 'worst', label: 'Worst' },
{ id: 'pace', label: 'Pace' },
]
const PLAYTIME: { id: PlaytimeMode; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'started', label: 'Started' },
{ id: 'untouched', label: 'Untouched' },
]
export function FilterPanel({ filters, defaults, update, onReset }: Props) {
return (
<aside className="panel">
<div className="panel-head">
<h2>Filters</h2>
<button type="button" className="ghost" onClick={onReset}>
Reset to CLI defaults
</button>
</div>
<section className="field">
<label>Estimate basis</label>
<div className="segmented">
{BASES.map((b) => (
<button
type="button"
key={b.id}
className={filters.basis === b.id ? 'seg active' : 'seg'}
onClick={() => update({ basis: b.id })}
>
{b.label}
</button>
))}
</div>
</section>
<section className="field">
<label>
Daily play time <span className="val">{filters.dailyHours} h/day</span>
</label>
<input
type="range"
min={0.5}
max={16}
step={0.5}
value={filters.dailyHours}
onChange={(e) => update({ dailyHours: Number(e.target.value) })}
/>
</section>
<section className="field">
<label>
Min HLTB completions{' '}
<span className="val">{filters.minCountComp}</span>
</label>
<input
type="range"
min={0}
max={100}
step={1}
value={filters.minCountComp}
onChange={(e) => update({ minCountComp: Number(e.target.value) })}
/>
<p className="hint">
Higher = more reliable HLTB times (CLI default {defaults.min_count_comp}).
</p>
</section>
<details className="field">
<summary>Advanced confidence</summary>
<label>
Min polled completionist times{' '}
<span className="val">{filters.minComp100}</span>
</label>
<input
type="range"
min={0}
max={50}
step={1}
value={filters.minComp100}
onChange={(e) => update({ minComp100: Number(e.target.value) })}
/>
<label>
Min confidence sum <span className="val">{filters.minConfidenceSum}</span>
</label>
<input
type="range"
min={0}
max={150}
step={1}
value={filters.minConfidenceSum}
onChange={(e) => update({ minConfidenceSum: Number(e.target.value) })}
/>
</details>
<section className="field">
<label>ProtonDB compatibility</label>
<div className="segmented">
<button
type="button"
className={filters.protonMode === 'playable' ? 'seg active' : 'seg'}
onClick={() => update({ protonMode: 'playable' })}
>
Playable (CLI rule)
</button>
<button
type="button"
className={filters.protonMode === 'minTier' ? 'seg active' : 'seg'}
onClick={() => update({ protonMode: 'minTier' })}
>
Min tier
</button>
</div>
{filters.protonMode === 'minTier' && (
<div className="subfield">
<select
value={filters.protonMinTier}
onChange={(e) => update({ protonMinTier: e.target.value })}
>
{SELECTABLE_TIERS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<label className="check">
<input
type="checkbox"
checked={filters.protonTreatMissingAsPass}
onChange={(e) =>
update({ protonTreatMissingAsPass: e.target.checked })
}
/>
Keep games with no ProtonDB data
</label>
</div>
)}
</section>
<section className="field">
<label>
Max time per game{' '}
<span className="val">
{filters.maxHoursPerGame > 0 ? `${filters.maxHoursPerGame} h` : 'off'}
</span>
</label>
<input
type="range"
min={0}
max={200}
step={5}
value={filters.maxHoursPerGame}
onChange={(e) => update({ maxHoursPerGame: Number(e.target.value) })}
/>
<p className="hint">Hide games longer than this (0 = no cap).</p>
</section>
<section className="field">
<label>Playtime</label>
<div className="segmented">
{PLAYTIME.map((p) => (
<button
type="button"
key={p.id}
className={filters.playtimeMode === p.id ? 'seg active' : 'seg'}
onClick={() => update({ playtimeMode: p.id })}
>
{p.label}
</button>
))}
</div>
</section>
<section className="field">
<label className="check">
<input
type="checkbox"
checked={filters.includeNoData}
onChange={(e) => update({ includeNoData: e.target.checked })}
/>
Include games with no HLTB data
</label>
{filters.includeNoData && (
<div className="subfield">
<label>
Fallback estimate{' '}
<span className="val">{filters.fallbackHours} h</span>
</label>
<input
type="range"
min={1}
max={100}
step={1}
value={filters.fallbackHours}
onChange={(e) => update({ fallbackHours: Number(e.target.value) })}
/>
</div>
)}
</section>
<section className="field">
<label>Target finish date</label>
<div className="subfield row">
<input
type="date"
value={filters.targetDate}
onChange={(e) => update({ targetDate: e.target.value })}
/>
{filters.targetDate && (
<button
type="button"
className="ghost"
onClick={() => update({ targetDate: '' })}
>
Clear
</button>
)}
</div>
<p className="hint">Pick a date to see the hours/day required.</p>
</section>
</aside>
)
}

View File

@ -0,0 +1,170 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { applyFilters } from '../estimate'
import type { GameRow } from '../estimate'
import type { WebGame } from '../types'
import { makeDataset, makeFilters, makeGame } from '../test/factories'
import { GameTable } from './GameTable'
function rowsFor(games: WebGame[]): GameRow[] {
return applyFilters(makeDataset(games), makeFilters()).rows.filter(
(r) => r.passesFilters,
)
}
describe('GameTable', () => {
it('renders the row count', () => {
const rows = rowsFor([makeGame({ app_id: 1 }), makeGame({ app_id: 2 })])
render(
<GameTable rows={rows} search="" onSearch={vi.fn()} onToggleExclude={vi.fn()} />,
)
expect(screen.getByText(/Games \(2\)/)).toBeInTheDocument()
})
it('filters rows by the search prop', () => {
const rows = rowsFor([
makeGame({ app_id: 1, name: 'Alpha' }),
makeGame({ app_id: 2, name: 'Beta' }),
])
render(
<GameTable
rows={rows}
search="alpha"
onSearch={vi.fn()}
onToggleExclude={vi.fn()}
/>,
)
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
})
it('calls onSearch as the user types', async () => {
const onSearch = vi.fn()
const rows = rowsFor([makeGame({ app_id: 1, name: 'Alpha' })])
render(
<GameTable rows={rows} search="" onSearch={onSearch} onToggleExclude={vi.fn()} />,
)
await userEvent.setup().type(screen.getByPlaceholderText(/Search games/i), 'x')
expect(onSearch).toHaveBeenCalledWith('x')
})
it('toggles sort direction when a header is clicked', async () => {
const rows = rowsFor([makeGame({ app_id: 1, name: 'Alpha' })])
render(
<GameTable rows={rows} search="" onSearch={vi.fn()} onToggleExclude={vi.fn()} />,
)
const user = userEvent.setup()
const header = screen.getByRole('columnheader', { name: /Game/ })
await user.click(header)
expect(header.textContent).toContain('▲')
await user.click(header)
expect(header.textContent).toContain('▼')
})
it('invokes onToggleExclude when a keep checkbox is clicked', async () => {
const onToggleExclude = vi.fn()
const rows = rowsFor([makeGame({ app_id: 42, name: 'Alpha' })])
render(
<GameTable
rows={rows}
search=""
onSearch={vi.fn()}
onToggleExclude={onToggleExclude}
/>,
)
await userEvent.setup().click(screen.getByRole('checkbox'))
expect(onToggleExclude).toHaveBeenCalledWith(42)
})
it('links to a direct HLTB page when the id is known, else search', () => {
const rows = rowsFor([
makeGame({ app_id: 1, name: 'Alpha', hltb_game_id: 555 }),
makeGame({ app_id: 2, name: 'Beta', hltb_game_id: 0 }),
])
render(
<GameTable rows={rows} search="" onSearch={vi.fn()} onToggleExclude={vi.fn()} />,
)
expect(screen.getByRole('link', { name: 'Alpha' })).toHaveAttribute(
'href',
'https://howlongtobeat.com/game/555',
)
expect(screen.getByRole('link', { name: 'Beta' })).toHaveAttribute(
'href',
'https://howlongtobeat.com/?q=Beta',
)
})
it('sorts by every column without error', async () => {
const rows = rowsFor([
makeGame({ app_id: 1, name: 'Alpha' }),
makeGame({ app_id: 2, name: 'Beta' }),
])
render(
<GameTable rows={rows} search="" onSearch={vi.fn()} onToggleExclude={vi.fn()} />,
)
const user = userEvent.setup()
for (const name of [
'%',
'Played',
'Rush',
'Leisure',
'Worst',
'HLTB n',
'ProtonDB',
'Game',
]) {
await user.click(screen.getByRole('columnheader', { name: new RegExp(name) }))
}
expect(screen.getByText('Alpha')).toBeInTheDocument()
})
it('shows a no-data badge for games without HLTB times', () => {
const rows = applyFilters(
makeDataset([
makeGame({
app_id: 1,
name: 'Alpha',
rush_hours: -1,
leisure_hours: -1,
worst_hours: -1,
}),
]),
makeFilters({ includeNoData: true }),
).rows.filter((r) => r.passesFilters)
render(
<GameTable rows={rows} search="" onSearch={vi.fn()} onToggleExclude={vi.fn()} />,
)
expect(screen.getByText('no data')).toBeInTheDocument()
})
it('triggers the va > vb comparator branch when rows are reverse-sorted', async () => {
// Gamma > Alpha alphabetically; default sort is leisure, so clicking Game header
// causes the comparator to see va='gamma' > vb='alpha', covering cmp=1.
const rows = rowsFor([
makeGame({ app_id: 1, name: 'Gamma' }),
makeGame({ app_id: 2, name: 'Alpha' }),
])
render(
<GameTable rows={rows} search="" onSearch={vi.fn()} onToggleExclude={vi.fn()} />,
)
await userEvent.setup().click(screen.getByRole('columnheader', { name: /Game/ }))
// After ascending name sort Alpha comes first.
expect(screen.getAllByRole('row')[1].textContent).toContain('Alpha')
})
it('caps the table and notes the overflow', () => {
const games = Array.from({ length: 301 }, (_, i) =>
makeGame({ app_id: i + 1, name: `Game ${i}` }),
)
render(
<GameTable
rows={rowsFor(games)}
search=""
onSearch={vi.fn()}
onToggleExclude={vi.fn()}
/>,
)
expect(screen.getByText(/Showing first 300 of 301/i)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,159 @@
import { useMemo, useState } from 'react'
import type { GameRow } from '../estimate'
import { fmtHoursPrecise, fmtPlaytime } from '../format'
import { tierLabel } from '../protondb'
interface Props {
rows: GameRow[]
search: string
onSearch: (value: string) => void
onToggleExclude: (appId: number) => void
}
type SortKey =
| 'name'
| 'completion'
| 'playtime'
| 'rush'
| 'leisure'
| 'worst'
| 'count_comp'
| 'proton'
const DISPLAY_CAP = 300
const COLUMNS: { key: SortKey; label: string; numeric: boolean }[] = [
{ key: 'name', label: 'Game', numeric: false },
{ key: 'completion', label: '%', numeric: true },
{ key: 'playtime', label: 'Played', numeric: true },
{ key: 'rush', label: 'Rush', numeric: true },
{ key: 'leisure', label: 'Leisure', numeric: true },
{ key: 'worst', label: 'Worst', numeric: true },
{ key: 'count_comp', label: 'HLTB n', numeric: true },
{ key: 'proton', label: 'ProtonDB', numeric: true },
]
function sortValue(row: GameRow, key: SortKey): number | string {
const g = row.game
switch (key) {
case 'name':
return g.name.toLowerCase()
case 'completion':
return g.completion_pct
case 'playtime':
return g.playtime_minutes
case 'rush':
return row.rush
case 'leisure':
return row.leisure
case 'worst':
return row.worst
case 'count_comp':
return g.count_comp
case 'proton':
return g.protondb_score
}
}
function hltbUrl(game: GameRow['game']): string {
if (game.hltb_game_id > 0) {
return `https://howlongtobeat.com/game/${game.hltb_game_id}`
}
return `https://howlongtobeat.com/?q=${encodeURIComponent(game.name)}`
}
export function GameTable({ rows, search, onSearch, onToggleExclude }: Props) {
const [sortKey, setSortKey] = useState<SortKey>('leisure')
const [asc, setAsc] = useState(true)
const visible = useMemo(() => {
const q = search.trim().toLowerCase()
const filtered = q
? rows.filter((r) => r.game.name.toLowerCase().includes(q))
: rows
const sorted = [...filtered].sort((a, b) => {
const va = sortValue(a, sortKey)
const vb = sortValue(b, sortKey)
const cmp = va < vb ? -1 : va > vb ? 1 : 0
return asc ? cmp : -cmp
})
return sorted
}, [rows, search, sortKey, asc])
const onHeader = (key: SortKey) => {
if (key === sortKey) setAsc(!asc)
else {
setSortKey(key)
setAsc(key === 'name')
}
}
const shown = visible.slice(0, DISPLAY_CAP)
return (
<div className="table-wrap">
<div className="table-head">
<h2>Games ({visible.length})</h2>
<input
type="search"
placeholder="Search games…"
value={search}
onChange={(e) => onSearch(e.target.value)}
/>
</div>
<div className="table-scroll">
<table>
<thead>
<tr>
{COLUMNS.map((c) => (
<th
key={c.key}
className={c.numeric ? 'num clickable' : 'clickable'}
onClick={() => onHeader(c.key)}
>
{c.label}
{sortKey === c.key ? (asc ? ' ▲' : ' ▼') : ''}
</th>
))}
<th>Keep</th>
</tr>
</thead>
<tbody>
{shown.map((r) => (
<tr key={r.game.app_id} className={r.excluded ? 'excluded' : ''}>
<td className="name">
<a href={hltbUrl(r.game)} target="_blank" rel="noreferrer">
{r.game.name}
</a>
{r.noData && <span className="badge">no data</span>}
</td>
<td className="num">{r.game.completion_pct.toFixed(0)}</td>
<td className="num">{fmtPlaytime(r.game.playtime_minutes)}</td>
<td className="num">{fmtHoursPrecise(r.rush)}</td>
<td className="num">{fmtHoursPrecise(r.leisure)}</td>
<td className="num">{fmtHoursPrecise(r.worst)}</td>
<td className="num">{r.game.count_comp}</td>
<td className="num proton">
{tierLabel(r.game.protondb_tier, r.game.protondb_trending_tier)}
</td>
<td className="num">
<input
type="checkbox"
checked={!r.excluded}
onChange={() => onToggleExclude(r.game.app_id)}
aria-label={r.excluded ? 'Re-include' : 'Exclude'}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{visible.length > DISPLAY_CAP && (
<p className="hint">
Showing first {DISPLAY_CAP} of {visible.length}. Use search to narrow.
</p>
)}
</div>
)
}

View File

@ -0,0 +1,47 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { applyFilters } from '../estimate'
import type { Filters } from '../types'
import { makeDataset, makeFilters, makeGame, makeState } from '../test/factories'
import { SummaryCards } from './SummaryCards'
function renderCards(filtersOver: Partial<Filters> = {}, statePace = 0) {
const filters = makeFilters(filtersOver)
const result = applyFilters(makeDataset([makeGame({ app_id: 1 })]), filters)
render(
<SummaryCards
result={result}
filters={filters}
state={makeState({ pace_games_per_day: statePace })}
presets={[2, 4, 6, 8]}
defaultQualifying={result.remainingGames}
/>,
)
return result
}
describe('SummaryCards', () => {
it('shows the in-scope count and a CLI-parity match badge', () => {
renderCards()
expect(screen.getByText(/games in scope/i)).toBeInTheDocument()
expect(screen.getByText(/match/i)).toBeInTheDocument()
})
it('notes a missing start date in the pace card', () => {
renderCards({}, 0)
expect(screen.getByText(/No start date set/i)).toBeInTheDocument()
})
it('renders the target-date banner with required hours/day', () => {
renderCards({ targetDate: '2099-01-01', basis: 'leisure' })
const banner = document.querySelector('.target-banner')
expect(banner?.textContent).toMatch(/you need/i)
expect(banner?.textContent).toMatch(/h\/day/i)
})
it('renders the target banner in games/day for the pace basis', () => {
renderCards({ targetDate: '2099-01-01', basis: 'pace' }, 0.9)
const banner = document.querySelector('.target-banner')
expect(banner?.textContent).toMatch(/games\/day/i)
})
})

View File

@ -0,0 +1,106 @@
import { basisTotal, etaDays, paceDays } from '../estimate'
import type { EstimateResult } from '../estimate'
import { daysUntil, fmtEta, fmtHours } from '../format'
import type { EstimateBasis, Filters, WebStateInfo } from '../types'
interface Props {
result: EstimateResult
filters: Filters
state: WebStateInfo
presets: number[]
defaultQualifying: number
}
interface CardData {
basis: EstimateBasis
title: string
blurb: string
}
const CARDS: CardData[] = [
{ basis: 'rush', title: 'Rush', blurb: 'Typical fast completionist' },
{ basis: 'leisure', title: 'Leisure', blurb: 'Slow, comfortable 100%' },
{ basis: 'worst', title: 'Worst case', blurb: 'Max recorded time' },
{ basis: 'pace', title: 'At your pace', blurb: 'Based on games finished' },
]
function TargetBanner({ result, filters }: Props) {
if (!filters.targetDate) return null
const days = Math.max(1, daysUntil(filters.targetDate))
let need: string
if (filters.basis === 'pace') {
const perDay = result.remainingGames / days
need = `${perDay.toFixed(2)} games/day`
} else {
const total = basisTotal(result, filters.basis) as number
need = `${(total / days).toFixed(1)} h/day`
}
return (
<div className="target-banner">
To finish <strong>{result.remainingGames}</strong> games by{' '}
<strong>{filters.targetDate}</strong> ({days} days) on the{' '}
<strong>{filters.basis}</strong> model, you need <strong>{need}</strong>.
</div>
)
}
export function SummaryCards(props: Props) {
const { result, filters, state, presets } = props
return (
<div className="summary">
<div className="summary-head">
<div>
<span className="big">{result.remainingGames.toLocaleString()}</span>
<span className="big-label">games in scope</span>
</div>
<div className="parity">
CLI default qualifies <strong>{props.defaultQualifying}</strong>
{result.remainingGames === props.defaultQualifying && (
<span className="ok"> match</span>
)}
</div>
</div>
<TargetBanner {...props} />
<div className="cards">
{CARDS.map((c) => {
const active = filters.basis === c.basis
const isPace = c.basis === 'pace'
const total = basisTotal(result, c.basis)
const headlineEta = isPace
? paceDays(result.remainingGames, state.pace_games_per_day)
: etaDays(total as number, filters.dailyHours)
return (
<div key={c.basis} className={active ? 'card active' : 'card'}>
<div className="card-title">{c.title}</div>
<div className="card-blurb">{c.blurb}</div>
<div className="card-total">
{isPace
? `${state.pace_games_per_day || 0} games/day`
: fmtHours(total as number)}
</div>
<div className="card-eta">
{isPace && !state.pace_games_per_day
? 'No start date set'
: fmtEta(headlineEta)}
</div>
{!isPace && (
<div className="presets">
{presets.map((h) => (
<div key={h} className="preset">
<span>{h} h/day</span>
<span>{fmtEta(etaDays(total as number, h))}</span>
</div>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { applyFilters } from '../estimate'
import type { EstimateBasis } from '../types'
import { makeDataset, makeFilters, makeGame, makeState } from '../test/factories'
import { TimelineChart } from './TimelineChart'
function renderChart(
count: number,
basis: EstimateBasis = 'leisure',
pace = 0,
) {
const games = Array.from({ length: count }, (_, i) =>
makeGame({ app_id: i + 1, name: `G${i}`, leisure_hours: 10 + i }),
)
const filters = makeFilters({ basis })
const result = applyFilters(makeDataset(games), filters)
return render(
<TimelineChart
result={result}
filters={filters}
state={makeState({ pace_games_per_day: pace })}
/>,
)
}
describe('TimelineChart', () => {
it('shows a fallback message with fewer than two games', () => {
renderChart(1)
expect(screen.getByText(/Not enough games/i)).toBeInTheDocument()
})
it('draws an SVG line for the hours basis', () => {
const { container } = renderChart(3, 'leisure')
expect(container.querySelector('svg.chart-svg')).not.toBeNull()
expect(container.querySelector('path.line')).not.toBeNull()
})
it('draws an SVG line for the pace basis when pace is known', () => {
const { container } = renderChart(3, 'pace', 0.5)
expect(container.querySelector('svg.chart-svg')).not.toBeNull()
})
it('shows the fallback message for the pace basis with no pace', () => {
renderChart(3, 'pace', 0)
expect(screen.getByText(/Not enough games/i)).toBeInTheDocument()
})
it('renders when the last timeline point has day=0 (covers || 1 fallback)', () => {
// Game 1: leisure_hours=0 but rush>0 so not noData → lengthHours=-1.
// Game 2: leisure_hours=1 → lengthHours=1.
// Cumulative with dailyHours=1: [-1, 0]. Last day=0 triggers `|| 1`.
const games = [
makeGame({ app_id: 1, name: 'G0', leisure_hours: 0 }),
makeGame({ app_id: 2, name: 'G1', leisure_hours: 1 }),
]
const filters = makeFilters({ basis: 'leisure', dailyHours: 1 })
const result = applyFilters(makeDataset(games), filters)
const { container } = render(
<TimelineChart result={result} filters={filters} state={makeState()} />,
)
expect(container.querySelector('svg.chart-svg')).not.toBeNull()
})
})

View File

@ -0,0 +1,102 @@
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>
)
}

175
web/src/estimate.test.ts Normal file
View File

@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest'
import { applyFilters, basisTotal, etaDays, paceDays } from './estimate'
import { makeDataset, makeFilters, makeGame } from './test/factories'
describe('applyFilters — totals and parity', () => {
it('sums each metric independently over qualifying games', () => {
const r = applyFilters(
makeDataset([
makeGame({ app_id: 1, rush_hours: 10, leisure_hours: 25, worst_hours: 30 }),
makeGame({ app_id: 2, rush_hours: 5, leisure_hours: 8, worst_hours: 9 }),
]),
makeFilters(),
)
expect(r.remainingGames).toBe(2)
expect(r.rushTotal).toBe(15)
expect(r.leisureTotal).toBe(33)
expect(r.worstTotal).toBe(39)
})
it('omits a missing metric from its total (partial data)', () => {
const r = applyFilters(
makeDataset([makeGame({ rush_hours: -1, leisure_hours: 20, worst_hours: 25 })]),
makeFilters(),
)
expect(r.rushTotal).toBe(0)
expect(r.leisureTotal).toBe(20)
})
it('skips non-positive leisure and worst when summing (rush basis)', () => {
const r = applyFilters(
makeDataset([makeGame({ rush_hours: 10, leisure_hours: -1, worst_hours: -1 })]),
makeFilters({ basis: 'rush' }),
)
expect(r.remainingGames).toBe(1)
expect(r.rushTotal).toBe(10)
expect(r.leisureTotal).toBe(0)
expect(r.worstTotal).toBe(0)
})
})
describe('applyFilters — threshold filters', () => {
it('excludes low-confidence games', () => {
const r = applyFilters(makeDataset([makeGame({ count_comp: 0 })]), makeFilters())
expect(r.remainingGames).toBe(0)
})
it('rejects unplayable games in playable mode', () => {
const r = applyFilters(
makeDataset([
makeGame({ protondb_tier: 'borked', protondb_trending_tier: 'borked' }),
]),
makeFilters(),
)
expect(r.remainingGames).toBe(0)
})
it('honours the min-tier ProtonDB mode', () => {
const g = makeGame({ protondb_tier: 'silver', protondb_trending_tier: 'silver' })
const r = applyFilters(
makeDataset([g]),
makeFilters({ protonMode: 'minTier', protonMinTier: 'silver' }),
)
expect(r.remainingGames).toBe(1)
})
it('filters by playtime (started vs untouched)', () => {
const games = [
makeGame({ app_id: 1, playtime_minutes: 0 }),
makeGame({ app_id: 2, playtime_minutes: 30 }),
]
expect(
applyFilters(
makeDataset(games),
makeFilters({ playtimeMode: 'started' }),
).included.map((r) => r.game.app_id),
).toEqual([2])
expect(
applyFilters(
makeDataset(games),
makeFilters({ playtimeMode: 'untouched' }),
).included.map((r) => r.game.app_id),
).toEqual([1])
})
it('applies the max time-per-game cap', () => {
const r = applyFilters(
makeDataset([
makeGame({ app_id: 1, leisure_hours: 10 }),
makeGame({ app_id: 2, leisure_hours: 80 }),
]),
makeFilters({ maxHoursPerGame: 50 }),
)
expect(r.included.map((x) => x.game.app_id)).toEqual([1])
})
})
describe('applyFilters — no-data handling', () => {
const noData = makeGame({ rush_hours: -1, leisure_hours: -1, worst_hours: -1 })
it('excludes no-data games by default', () => {
expect(applyFilters(makeDataset([noData]), makeFilters()).remainingGames).toBe(0)
})
it('includes no-data games with the fallback applied to every metric', () => {
const r = applyFilters(
makeDataset([noData]),
makeFilters({ includeNoData: true, fallbackHours: 12 }),
)
expect(r.remainingGames).toBe(1)
expect(r.rushTotal).toBe(12)
expect(r.leisureTotal).toBe(12)
expect(r.worstTotal).toBe(12)
})
})
describe('applyFilters — exclusions', () => {
it('keeps passesFilters true but drops a manually excluded game from totals', () => {
const r = applyFilters(
makeDataset([makeGame({ app_id: 1 })]),
makeFilters({ excluded: new Set([1]) }),
)
expect(r.remainingGames).toBe(0)
expect(r.rows[0].passesFilters).toBe(true)
expect(r.rows[0].excluded).toBe(true)
expect(r.rows[0].included).toBe(false)
})
})
describe('basis length proxy', () => {
it('uses leisure as the length proxy for the pace basis', () => {
const r = applyFilters(
makeDataset([makeGame({ leisure_hours: 30 })]),
makeFilters({ basis: 'pace' }),
)
expect(r.included[0].lengthHours).toBe(30)
})
it('uses rush hours as length for the rush basis', () => {
const r = applyFilters(
makeDataset([makeGame({ rush_hours: 7 })]),
makeFilters({ basis: 'rush' }),
)
expect(r.included[0].lengthHours).toBe(7)
})
it('uses worst hours as length for the worst basis', () => {
const r = applyFilters(
makeDataset([makeGame({ worst_hours: 99 })]),
makeFilters({ basis: 'worst', maxHoursPerGame: 0 }),
)
expect(r.included[0].lengthHours).toBe(99)
})
})
describe('etaDays / paceDays / basisTotal', () => {
it('etaDays floors and guards zero inputs', () => {
expect(etaDays(40, 4)).toBe(10)
expect(etaDays(0, 4)).toBeNull()
expect(etaDays(40, 0)).toBeNull()
})
it('paceDays floors and guards zero inputs', () => {
expect(paceDays(10, 0.5)).toBe(20)
expect(paceDays(0, 1)).toBeNull()
expect(paceDays(10, 0)).toBeNull()
})
it('basisTotal returns the right total or null for pace', () => {
const r = applyFilters(makeDataset([makeGame()]), makeFilters())
expect(basisTotal(r, 'rush')).toBe(r.rushTotal)
expect(basisTotal(r, 'leisure')).toBe(r.leisureTotal)
expect(basisTotal(r, 'worst')).toBe(r.worstTotal)
expect(basisTotal(r, 'pace')).toBeNull()
})
})

160
web/src/estimate.ts Normal file
View File

@ -0,0 +1,160 @@
// Pure filtering + completion-time estimation. Mirrors the logic in
// steam_backlog_enforcer/_stats.py and _web_dataset.py so that, at the CLI
// default thresholds, the totals reproduce the `stats` command exactly.
import { isPlayable, passesMinTier } from './protondb'
import type { EstimateBasis, Filters, WebDataset, WebGame } from './types'
export interface GameRow {
game: WebGame
// Effective hours per model (fallback applied for no-data games when the
// "include no-data" toggle is on; otherwise -1 when missing).
rush: number
leisure: number
worst: number
// Hours under the selected basis — used for the cap, table, and chart.
lengthHours: number
noData: boolean
passesFilters: boolean // all threshold/extra filters (not manual exclusion)
excluded: boolean // manually excluded by the user
included: boolean // counted in totals
}
export interface EstimateResult {
rows: GameRow[]
included: GameRow[]
rushTotal: number
leisureTotal: number
worstTotal: number
remainingGames: number
}
/** True when HLTB has no length of any kind for this game. */
function isNoData(g: WebGame): boolean {
return g.rush_hours <= 0 && g.leisure_hours <= 0 && g.worst_hours <= 0
}
/** Raw per-game hours for the selected basis (pace uses leisure as a proxy). */
function rawBasisHours(g: WebGame, basis: EstimateBasis): number {
switch (basis) {
case 'rush':
return g.rush_hours
case 'worst':
return g.worst_hours
case 'leisure':
case 'pace':
return g.leisure_hours
}
}
/** Apply the no-data fallback: keep positive values, substitute fallback when
* the whole game is missing and the user opted to include such games. */
function effective(raw: number, noData: boolean, f: Filters): number {
if (raw > 0) return raw
if (noData && f.includeNoData) return f.fallbackHours
return -1
}
function passesConfidence(g: WebGame, f: Filters): boolean {
return (
g.comp_100_count >= f.minComp100 &&
g.count_comp >= f.minCountComp &&
g.comp_100_count + g.count_comp >= f.minConfidenceSum
)
}
function passesProton(g: WebGame, f: Filters): boolean {
if (f.protonMode === 'playable') {
return isPlayable(g.protondb_tier, g.protondb_trending_tier)
}
return passesMinTier(
g.protondb_tier,
g.protondb_trending_tier,
f.protonMinTier,
f.protonTreatMissingAsPass,
)
}
function passesPlaytime(g: WebGame, f: Filters): boolean {
if (f.playtimeMode === 'started') return g.playtime_minutes > 0
if (f.playtimeMode === 'untouched') return g.playtime_minutes === 0
return true
}
function buildRow(g: WebGame, f: Filters): GameRow {
const noData = isNoData(g)
const rush = effective(g.rush_hours, noData, f)
const leisure = effective(g.leisure_hours, noData, f)
const worst = effective(g.worst_hours, noData, f)
const lengthHours = effective(rawBasisHours(g, f.basis), noData, f)
// Threshold + extra filters, evaluated independently of manual exclusion.
let passes = passesConfidence(g, f) && passesProton(g, f) && passesPlaytime(g, f)
if (passes && !f.includeNoData && noData) passes = false
if (passes && f.maxHoursPerGame > 0 && lengthHours > f.maxHoursPerGame) {
passes = false
}
const excluded = f.excluded.has(g.app_id)
return {
game: g,
rush,
leisure,
worst,
lengthHours,
noData,
passesFilters: passes,
excluded,
included: passes && !excluded,
}
}
/** Run all filters and compute the qualifying totals. */
export function applyFilters(
dataset: WebDataset,
filters: Filters,
): EstimateResult {
const rows = dataset.games.map((g) => buildRow(g, filters))
const included = rows.filter((r) => r.included)
let rushTotal = 0
let leisureTotal = 0
let worstTotal = 0
for (const r of included) {
if (r.rush > 0) rushTotal += r.rush
if (r.leisure > 0) leisureTotal += r.leisure
if (r.worst > 0) worstTotal += r.worst
}
return {
rows,
included,
rushTotal: Math.round(rushTotal * 10) / 10,
leisureTotal: Math.round(leisureTotal * 10) / 10,
worstTotal: Math.round(worstTotal * 10) / 10,
remainingGames: included.length,
}
}
/** Days to finish `hours` at `daily` hours/day (floor — matches the CLI). */
export function etaDays(hours: number, daily: number): number | null {
if (hours <= 0 || daily <= 0) return null
return Math.floor(hours / daily)
}
/** Days to finish `remaining` games at `pace` games/day (floor). */
export function paceDays(remaining: number, pace: number): number | null {
if (remaining <= 0 || pace <= 0) return null
return Math.floor(remaining / pace)
}
/** Total hours for the selected basis, or null for the pace (count) basis. */
export function basisTotal(
result: EstimateResult,
basis: EstimateBasis,
): number | null {
if (basis === 'rush') return result.rushTotal
if (basis === 'leisure') return result.leisureTotal
if (basis === 'worst') return result.worstTotal
return null
}

68
web/src/format.test.ts Normal file
View File

@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
daysUntil,
fmtEta,
fmtHours,
fmtHoursPrecise,
fmtPlaytime,
isoDate,
} from './format'
afterEach(() => {
vi.useRealTimers()
})
describe('fmtHours', () => {
it('returns a dash for non-positive values', () => {
expect(fmtHours(0)).toBe('—')
expect(fmtHours(-3)).toBe('—')
})
it('rounds with thousands separators', () => {
expect(fmtHours(67031.1)).toBe('67,031 h')
})
})
describe('fmtHoursPrecise', () => {
it('returns a dash for non-positive values', () => {
expect(fmtHoursPrecise(-1)).toBe('—')
})
it('keeps one decimal place', () => {
expect(fmtHoursPrecise(44.28)).toBe('44.3')
})
})
describe('fmtPlaytime', () => {
it('reports untouched at zero', () => {
expect(fmtPlaytime(0)).toBe('untouched')
})
it('converts minutes to hours', () => {
expect(fmtPlaytime(90)).toBe('1.5 h')
})
})
describe('fmtEta', () => {
it('returns N/A for null', () => {
expect(fmtEta(null)).toBe('N/A')
})
it('returns days and the target date', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-29T12:00:00'))
const out = fmtEta(10)
expect(out).toContain('10 days')
expect(out).toContain('2026-06-08')
})
})
describe('isoDate', () => {
it('formats a date as YYYY-MM-DD', () => {
expect(isoDate(new Date(2026, 0, 5))).toBe('2026-01-05')
})
})
describe('daysUntil', () => {
it('counts whole days to a future date', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-29T12:00:00'))
expect(daysUntil('2026-06-08')).toBe(10)
})
})

44
web/src/format.ts Normal file
View File

@ -0,0 +1,44 @@
// Small display-formatting helpers shared across components.
/** Format an hour count with thousands separators, e.g. 67031.1 → "67,031 h". */
export function fmtHours(hours: number): string {
if (hours <= 0) return '—'
return `${Math.round(hours).toLocaleString('en-US')} h`
}
/** Format a possibly-missing per-game hour value, e.g. 44.3 → "44.3". */
export function fmtHoursPrecise(hours: number): string {
if (hours <= 0) return '—'
return hours.toFixed(1)
}
/** Format playtime minutes as hours, e.g. 320 → "5.3 h" (0 → "untouched"). */
export function fmtPlaytime(minutes: number): string {
if (minutes <= 0) return 'untouched'
return `${(minutes / 60).toFixed(1)} h`
}
/** Format a day count and its target date, e.g. (866) → "866 days · 2028-10-11". */
export function fmtEta(days: number | null): string {
if (days === null) return 'N/A'
const target = new Date()
target.setDate(target.getDate() + days)
return `${days.toLocaleString('en-US')} days · ${isoDate(target)}`
}
/** Render a Date as YYYY-MM-DD in local time. */
export function isoDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/** Whole days from today (local midnight) until the given ISO date string. */
export function daysUntil(isoDateStr: string): number {
const target = new Date(`${isoDateStr}T00:00:00`)
const today = new Date()
today.setHours(0, 0, 0, 0)
const ms = target.getTime() - today.getTime()
return Math.ceil(ms / (1000 * 60 * 60 * 24))
}

431
web/src/index.css Normal file
View File

@ -0,0 +1,431 @@
:root {
--bg: #0f1117;
--panel: #161922;
--panel-2: #1b2030;
--card: #1b2838;
--border: #2a2f3a;
--text: #c6d4df;
--muted: #8b95a1;
--heading: #ffffff;
--accent: #66c0f4;
--accent-2: #a3cf06;
--warn: #f0a23b;
--danger: #e35d5d;
--radius: 10px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, 'SF Mono', Consolas, monospace;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.5 var(--sans);
}
h1,
h2 {
color: var(--heading);
font-weight: 600;
margin: 0;
}
h1 {
font-size: 26px;
letter-spacing: -0.4px;
}
h2 {
font-size: 16px;
margin-bottom: 10px;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code {
font-family: var(--mono);
background: var(--panel-2);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.app {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.app-head {
margin-bottom: 20px;
}
.sub {
color: var(--muted);
margin-top: 6px;
}
.status {
max-width: 640px;
margin: 12vh auto;
text-align: center;
}
.error {
color: var(--danger);
}
.hint {
color: var(--muted);
font-size: 13px;
margin: 6px 0 0;
}
/* ── Layout ── */
.layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 24px;
align-items: start;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
}
.content {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
/* ── Filter panel ── */
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
position: sticky;
top: 16px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.field {
padding: 12px 0;
border-top: 1px solid var(--border);
}
.field > label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--heading);
margin-bottom: 8px;
}
.field .val {
float: right;
color: var(--accent);
font-family: var(--mono);
font-weight: 400;
}
.field input[type='range'] {
width: 100%;
accent-color: var(--accent);
}
.subfield {
margin-top: 10px;
}
.subfield.row {
display: flex;
gap: 8px;
align-items: center;
}
.check {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text);
cursor: pointer;
}
.check input {
accent-color: var(--accent);
}
select,
input[type='date'],
input[type='search'] {
width: 100%;
background: var(--panel-2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 7px 9px;
font: inherit;
}
details summary {
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--heading);
}
details[open] summary {
margin-bottom: 8px;
}
/* ── Buttons / segmented ── */
.segmented {
display: flex;
gap: 4px;
background: var(--panel-2);
padding: 4px;
border-radius: 8px;
}
.seg {
flex: 1;
border: none;
background: transparent;
color: var(--muted);
padding: 6px 4px;
border-radius: 6px;
cursor: pointer;
font: inherit;
font-size: 12px;
}
.seg.active {
background: var(--accent);
color: #06121c;
font-weight: 600;
}
.ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 5px 10px;
border-radius: 6px;
cursor: pointer;
font: inherit;
font-size: 12px;
}
.ghost:hover {
color: var(--heading);
border-color: var(--accent);
}
/* ── Summary ── */
.summary {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
}
.summary-head {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.big {
font-size: 34px;
font-weight: 700;
color: var(--heading);
}
.big-label {
margin-left: 8px;
color: var(--muted);
}
.parity {
color: var(--muted);
font-size: 13px;
}
.parity .ok {
color: var(--accent-2);
font-weight: 600;
}
.target-banner {
background: var(--accent-bg, rgba(102, 192, 244, 0.12));
border: 1px solid rgba(102, 192, 244, 0.4);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 14px;
font-size: 14px;
}
.cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
@media (max-width: 720px) {
.cards {
grid-template-columns: repeat(2, 1fr);
}
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
}
.card.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.card-title {
font-weight: 600;
color: var(--heading);
}
.card-blurb {
font-size: 12px;
color: var(--muted);
margin-bottom: 10px;
min-height: 28px;
}
.card-total {
font-size: 22px;
font-weight: 700;
color: var(--accent);
font-family: var(--mono);
}
.card-eta {
font-size: 13px;
color: var(--text);
margin-top: 2px;
}
.presets {
margin-top: 10px;
border-top: 1px solid var(--border);
padding-top: 8px;
}
.preset {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--muted);
padding: 1px 0;
}
/* ── Chart ── */
.chart {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
}
.chart-svg {
width: 100%;
height: auto;
display: block;
}
.chart-svg .grid {
stroke: var(--border);
stroke-width: 1;
}
.chart-svg .line {
fill: none;
stroke: var(--accent);
stroke-width: 2.5;
}
.chart-svg .area {
fill: rgba(102, 192, 244, 0.12);
stroke: none;
}
.chart-svg .axis-label {
fill: var(--muted);
font-size: 11px;
font-family: var(--mono);
}
.chart-svg .axis-label.end {
text-anchor: end;
}
.chart-svg .axis-label.mid {
text-anchor: middle;
}
/* ── Table ── */
.table-wrap {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
}
.table-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.table-head input {
max-width: 260px;
}
.table-scroll {
max-height: 540px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead th {
position: sticky;
top: 0;
background: var(--panel-2);
color: var(--heading);
text-align: left;
padding: 9px 10px;
font-weight: 600;
white-space: nowrap;
z-index: 1;
}
th.num,
td.num {
text-align: right;
}
th.clickable {
cursor: pointer;
user-select: none;
}
tbody td {
padding: 7px 10px;
border-top: 1px solid var(--border);
}
tbody tr:hover {
background: var(--panel-2);
}
tr.excluded {
opacity: 0.4;
}
tr.excluded .name a {
text-decoration: line-through;
}
td.name {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
td.proton {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}
.badge {
margin-left: 8px;
font-size: 10px;
background: var(--warn);
color: #1a1205;
padding: 1px 5px;
border-radius: 4px;
vertical-align: middle;
}

10
web/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

57
web/src/protondb.test.ts Normal file
View File

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { isPlayable, passesMinTier, tierLabel } from './protondb'
describe('isPlayable (faithful CLI compound rule)', () => {
it('keeps games with no rating or pending', () => {
expect(isPlayable('', '')).toBe(true)
expect(isPlayable('pending', '')).toBe(true)
})
it('single rating must be gold-or-better', () => {
expect(isPlayable('platinum', '')).toBe(true)
expect(isPlayable('gold', '')).toBe(true)
expect(isPlayable('silver', '')).toBe(false)
})
it('rejects when either rating is below silver', () => {
expect(isPlayable('gold', 'bronze')).toBe(false)
expect(isPlayable('bronze', 'gold')).toBe(false)
})
it('rejects when neither rating reaches gold', () => {
expect(isPlayable('silver', 'silver')).toBe(false)
})
it('accepts when one is gold-or-better and the other silver-or-better', () => {
expect(isPlayable('gold', 'silver')).toBe(true)
expect(isPlayable('silver', 'gold')).toBe(true)
expect(isPlayable('platinum', 'platinum')).toBe(true)
})
it('treats unknown tiers as below silver', () => {
expect(isPlayable('mystery', 'mystery')).toBe(false)
})
})
describe('passesMinTier', () => {
it('honours treatMissingAsPass when no data', () => {
expect(passesMinTier('', '', 'gold', true)).toBe(true)
expect(passesMinTier('', '', 'gold', false)).toBe(false)
expect(passesMinTier('pending', 'pending', 'gold', false)).toBe(false)
})
it('uses the best of the two ratings', () => {
expect(passesMinTier('silver', 'gold', 'gold', false)).toBe(true)
expect(passesMinTier('silver', 'silver', 'gold', false)).toBe(false)
expect(passesMinTier('platinum', '', 'gold', false)).toBe(true)
})
})
describe('tierLabel', () => {
it('renders a dash, single, or paired label', () => {
expect(tierLabel('', '')).toBe('—')
expect(tierLabel('gold', 'gold')).toBe('gold')
expect(tierLabel('gold', '')).toBe('gold')
expect(tierLabel('gold', 'silver')).toBe('gold / silver')
})
})

72
web/src/protondb.ts Normal file
View File

@ -0,0 +1,72 @@
// ProtonDB tier logic. `isPlayable` is a faithful port of
// ProtonDBRating.is_playable in steam_backlog_enforcer/protondb.py, so the
// UI's default ProtonDB filter reproduces the CLI's qualifying set exactly.
export const TIER_ORDER: Record<string, number> = {
native: 0,
platinum: 1,
gold: 2,
silver: 3,
bronze: 4,
borked: 5,
pending: 6,
}
// Tiers offered in the "minimum tier" dropdown, best → worst.
export const SELECTABLE_TIERS = [
'native',
'platinum',
'gold',
'silver',
'bronze',
] as const
const MIN_PLAYABLE_TIER = 'gold'
const UNKNOWN_RANK = 99
function rank(tier: string): number {
return TIER_ORDER[tier] ?? UNKNOWN_RANK
}
/**
* Faithful port of the CLI's compound playability rule.
*
* A game with no rating (or "pending") is not blocked. With a single rating,
* it must be gold-or-better. With both `tier` and `trending`, neither may be
* below silver and at least one must be gold-or-better.
*/
export function isPlayable(tier: string, trending: string): boolean {
if (!tier || tier === 'pending') return true
const tierRank = rank(tier)
const minRank = TIER_ORDER[MIN_PLAYABLE_TIER]
const silverRank = TIER_ORDER.silver
if (!trending) return tierRank <= minRank
const trendRank = rank(trending)
if (tierRank > silverRank || trendRank > silverRank) return false
return !(tierRank > minRank && trendRank > minRank)
}
/**
* Simple "minimum acceptable tier" rule for the manual ProtonDB mode.
*
* Uses the better (lower-rank) of the two available ratings. Games with no
* rating fall back to `treatMissingAsPass`.
*/
export function passesMinTier(
tier: string,
trending: string,
minTier: string,
treatMissingAsPass: boolean,
): boolean {
const present = [tier, trending].filter((t) => t && t !== 'pending')
if (present.length === 0) return treatMissingAsPass
const bestRank = Math.min(...present.map(rank))
return bestRank <= rank(minTier)
}
/** A short, human-readable label for a game's ProtonDB rating. */
export function tierLabel(tier: string, trending: string): string {
if (!tier) return '—'
if (trending && trending !== tier) return `${tier} / ${trending}`
return tier
}

81
web/src/test/factories.ts Normal file
View File

@ -0,0 +1,81 @@
// Shared test factories. Lives under src/test/ which is excluded from the
// app build and from coverage.
import type { Filters, WebDataset, WebGame, WebStateInfo } from '../types'
export function makeGame(over: Partial<WebGame> = {}): WebGame {
return {
app_id: 1,
name: 'Game',
completion_pct: 0,
playtime_minutes: 60,
rush_hours: 10,
leisure_hours: 20,
worst_hours: 25,
count_comp: 20,
comp_100_count: 5,
hltb_game_id: 0,
protondb_tier: 'gold',
protondb_trending_tier: 'gold',
protondb_score: 0.8,
...over,
}
}
export function makeFilters(over: Partial<Filters> = {}): Filters {
return {
minCountComp: 15,
minComp100: 3,
minConfidenceSum: 18,
protonMode: 'playable',
protonMinTier: 'gold',
protonTreatMissingAsPass: true,
dailyHours: 4,
basis: 'leisure',
maxHoursPerGame: 0,
playtimeMode: 'all',
includeNoData: false,
fallbackHours: 20,
excluded: new Set<number>(),
search: '',
targetDate: '',
...over,
}
}
export function makeState(over: Partial<WebStateInfo> = {}): WebStateInfo {
return {
current_app_id: null,
current_game_name: '',
games_done: 0,
days_elapsed: 0,
enforcement_started_at: '',
pace_games_per_day: 0,
...over,
}
}
export function makeDataset(
games: WebGame[] = [makeGame()],
over: Partial<WebDataset> = {},
): WebDataset {
return {
games,
state: makeState(),
defaults: {
min_comp_100_polls: 3,
min_count_comp: 15,
min_confidence_sum: 18,
min_playable_tier: 'gold',
hours_per_day_presets: [2, 4, 6, 8],
},
default_summary: {
qualifying: games.length,
rush_total: 0,
leisure_total: 0,
worst_total: 0,
},
generated_at: '2026-05-29T00:00:00+00:00',
...over,
}
}

8
web/src/test/setup.ts Normal file
View File

@ -0,0 +1,8 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// React Testing Library does not auto-clean when Vitest globals are disabled.
afterEach(() => {
cleanup()
})

85
web/src/types.ts Normal file
View File

@ -0,0 +1,85 @@
// Type definitions mirroring the JSON payload from `GET /api/dataset`
// (see steam_backlog_enforcer/_web_dataset.py). Hour fields use -1 for
// "no data", matching the cache convention.
export interface WebGame {
app_id: number
name: string
completion_pct: number
playtime_minutes: number
rush_hours: number
leisure_hours: number
worst_hours: number
count_comp: number
comp_100_count: number
hltb_game_id: number
protondb_tier: string
protondb_trending_tier: string
protondb_score: number
}
export interface WebStateInfo {
current_app_id: number | null
current_game_name: string
games_done: number
days_elapsed: number
enforcement_started_at: string
pace_games_per_day: number
}
export interface WebDefaults {
min_comp_100_polls: number
min_count_comp: number
min_confidence_sum: number
min_playable_tier: string
hours_per_day_presets: number[]
}
export interface DefaultSummary {
qualifying: number
rush_total: number
leisure_total: number
worst_total: number
}
export interface WebDataset {
games: WebGame[]
state: WebStateInfo
defaults: WebDefaults
default_summary: DefaultSummary
generated_at: string
}
// Which time model drives the headline finish-date and the timeline chart.
export type EstimateBasis = 'rush' | 'leisure' | 'worst' | 'pace'
// How the ProtonDB compatibility filter behaves.
// - 'playable': faithful port of the CLI's compound `is_playable` rule.
// - 'minTier': simple "best available tier must be at least X".
export type ProtonMode = 'playable' | 'minTier'
export type PlaytimeMode = 'all' | 'started' | 'untouched'
export interface Filters {
// HLTB confidence thresholds (CLI defaults: 15 / 3 / 18).
minCountComp: number
minComp100: number
minConfidenceSum: number
// ProtonDB compatibility.
protonMode: ProtonMode
protonMinTier: string
protonTreatMissingAsPass: boolean
// Time budget + which model is primary.
dailyHours: number
basis: EstimateBasis
// Extra filters.
maxHoursPerGame: number // 0 = no cap
playtimeMode: PlaytimeMode
includeNoData: boolean
fallbackHours: number
// Manual exclusions (Steam app IDs) and table search.
excluded: ReadonlySet<number>
search: string
// Target-date planner ('' = disabled), ISO yyyy-mm-dd.
targetDate: string
}

24
web/tsconfig.app.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
}

7
web/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
web/tsconfig.node.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

34
web/vite.config.ts Normal file
View File

@ -0,0 +1,34 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
// The Python API (`./run.sh serve`) runs on 127.0.0.1:8000. In dev, Vite
// serves the UI and proxies `/api/*` to it, so there are no CORS concerns.
// In production the Python server serves the built bundle from `web/dist`.
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://127.0.0.1:8000',
},
},
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.test.{ts,tsx}',
'src/test/**',
'src/main.tsx',
'src/vite-env.d.ts',
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
},
})