mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-07-04 12:03:13 +02:00
Adds a React/TypeScript frontend (web/) with a Python stdlib HTTP server backend. The UI mirrors the CLI `stats` command in the browser, with real-time sliders for ProtonDB rating, HLTB confidence thresholds, daily play time, per-game time cap, playtime mode, no-HLTB-data fallback, and a target-date planner. A parity badge confirms the client-side totals reproduce the CLI defaults exactly (786 / 67031.1h / 143017.2h / 238447.9h). Python side: - `_web_dataset.py`: offline projection of HLTB/ProtonDB/snapshot caches into a compact, secret-free JSON payload; 100% branch coverage - `_web_server.py`: zero-dependency stdlib HTTP server serving the built Vite bundle and the /api/dataset endpoint; 100% branch coverage - `main.py`: new `serve` command wiring Frontend (Vitest + RTL, 100% branch coverage enforced): - TypeScript port of ProtonDB compound rating rule with full parity - Pure client-side filtering via estimate.ts (no server round-trips) - SVG completion timeline chart, sortable/searchable game table - Steam dark theme; responsive layout Pre-commit: adds `vitest-coverage` hook at pre-push stage requiring 100% branch coverage on the React codebase. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
281 lines
9.1 KiB
Python
281 lines
9.1 KiB
Python
"""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)
|