mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-07-04 10:23:41 +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/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Web UI (Vite + React frontend)
|
||||
node_modules/
|
||||
web/dist/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
@ -141,6 +141,20 @@ repos:
|
||||
require_serial: true
|
||||
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
|
||||
# ===========================================================================
|
||||
|
||||
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._stats import cmd_stats
|
||||
from steam_backlog_enforcer._web_server import serve
|
||||
from steam_backlog_enforcer._whitelist import (
|
||||
WHITELIST_COOLDOWN_SECONDS,
|
||||
add_pending_exception,
|
||||
@ -381,6 +382,11 @@ def cmd_pick(config: Config, state: State) -> None:
|
||||
_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]]] = {
|
||||
"scan": ("Scan library & assign a game", do_scan),
|
||||
"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),
|
||||
"pick": ("Manually pick your next game from candidates", cmd_pick),
|
||||
"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).
|
||||
|
||||
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