mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-07-04 13:43:45 +02:00
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:
parent
f845273ee7
commit
41deb90324
6
.gitignore
vendored
6
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
280
steam_backlog_enforcer/_web_dataset.py
Normal file
280
steam_backlog_enforcer/_web_dataset.py
Normal 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)
|
||||||
109
steam_backlog_enforcer/_web_server.py
Normal file
109
steam_backlog_enforcer/_web_server.py
Normal 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()
|
||||||
@ -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).
|
||||||
|
|||||||
330
steam_backlog_enforcer/tests/test_web_dataset.py
Normal file
330
steam_backlog_enforcer/tests/test_web_dataset.py
Normal 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)
|
||||||
181
steam_backlog_enforcer/tests/test_web_server.py
Normal file
181
steam_backlog_enforcer/tests/test_web_server.py
Normal 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
24
web/.gitignore
vendored
Normal 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
73
web/README.md
Normal 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
22
web/eslint.config.js
Normal 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
13
web/index.html
Normal 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
4051
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
web/package.json
Normal file
40
web/package.json
Normal 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
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
96
web/src/App.test.tsx
Normal 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
129
web/src/App.tsx
Normal 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
10
web/src/api.ts
Normal 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
|
||||||
|
}
|
||||||
122
web/src/components/FilterPanel.test.tsx
Normal file
122
web/src/components/FilterPanel.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
241
web/src/components/FilterPanel.tsx
Normal file
241
web/src/components/FilterPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
170
web/src/components/GameTable.test.tsx
Normal file
170
web/src/components/GameTable.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
159
web/src/components/GameTable.tsx
Normal file
159
web/src/components/GameTable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
web/src/components/SummaryCards.test.tsx
Normal file
47
web/src/components/SummaryCards.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
106
web/src/components/SummaryCards.tsx
Normal file
106
web/src/components/SummaryCards.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
web/src/components/TimelineChart.test.tsx
Normal file
64
web/src/components/TimelineChart.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
102
web/src/components/TimelineChart.tsx
Normal file
102
web/src/components/TimelineChart.tsx
Normal 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
175
web/src/estimate.test.ts
Normal 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
160
web/src/estimate.ts
Normal 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
68
web/src/format.test.ts
Normal 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
44
web/src/format.ts
Normal 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
431
web/src/index.css
Normal 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
10
web/src/main.tsx
Normal 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
57
web/src/protondb.test.ts
Normal 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
72
web/src/protondb.ts
Normal 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
81
web/src/test/factories.ts
Normal 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
8
web/src/test/setup.ts
Normal 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
85
web/src/types.ts
Normal 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
24
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
22
web/tsconfig.node.json
Normal file
22
web/tsconfig.node.json
Normal 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
34
web/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user