Serve the lock decision, its history and its health at 127.0.0.1:8770

The only status surface was a GTK tray icon, commented out of ~/.config/i3
on 2026-08-21. So when the weekly count went wrong, nothing on the machine
said so for two weeks -- the same silence that let a deleted timer job stop
enforcement for thirteen days in August.

Three read-only endpoints, and a React 19 UI over them:

  /api/status     the decision, the week, and whether today has a workout
  /api/decisions  the recorded trail, newest first
  /api/health     timers armed, disarm marker, log and trail freshness

The Why view is the one that matters: it renders the run of
"weekly_minimum_met 5/5" ticks that hid this week's bug, without anyone
having to reach for journalctl.

/api/status also carries gaming.workout_today, which is the fact
steam-backlog-enforcer will cut the daily budget on. Deliberately just the
fact -- the enforcer owns the hour values, so the two repos cannot come to
disagree about what a workout day is. It is derived from count_day_credits,
the same rule the locker enforces with.

_compliance_state_word moves from status_view (tkinter) into
_status_projection, and the systemd arming check moves from
scripts/check_enforcement_armed.py into screen_locker/_armed_state.py, so
the server can answer "is enforcement armed" without a second copy of the
logic that would be free to disagree with the gate.

web/ mirrors steam-backlog-enforcer/web but tightens it: that config has no
"strict" in any tsconfig, extends only tseslint recommended (no type-aware
rules at all), and gates neither lint nor typecheck in pre-commit. Here all
three run, on strictTypeChecked + stylisticTypeChecked, with
noUncheckedIndexedAccess and exactOptionalPropertyTypes. 44 tests, 100% of
statements, branches, functions and lines.

typescript stays pinned at 6.0.3: 7.0.2 is out, but every published
typescript-eslint -- stable, canary and rc-v8 -- declares
typescript ">=4.8.4 <6.1.0". The fleet allowlist already carries that entry.

The 250-line cap now covers ts/tsx. It was python/shell/dart only, which
would have made web/ exempt from the gate the rest of the repo lives under
-- the same omission that exempted the whole Flutter app until 2026-08-15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
This commit is contained in:
Krzysztof Rudnicki 2026-08-29 22:50:19 +02:00
parent dd8a39ade5
commit 8f911585c1
46 changed files with 6778 additions and 121 deletions

7
.gitignore vendored
View File

@ -30,3 +30,10 @@ screen_locker/early_bird_pending.json
# ci_mirror.sh cached requirements.txt venv (rebuilt on demand)
.ci-mirror-venv/
# Web status UI (web/) build and tooling output.
web/node_modules/
web/dist/
web/coverage/
web/node_modules
web/dist

View File

@ -195,8 +195,11 @@ repos:
language: system
# Dart was missing here until 2026-08-15, so the cap had never looked at
# a single file in workout_app/ — the whole Flutter app was exempt from
# the gate it was being refactored to satisfy.
types_or: [python, shell, dart]
# the gate it was being refactored to satisfy. ts/tsx joined on
# 2026-08-29 with web/, for exactly the same reason: a language absent
# from this list is a directory the cap silently does not apply to.
types_or: [python, shell, dart, ts, tsx]
exclude: '^web/(node_modules|dist|coverage)/'
- id: no-silent-failures
name: Block silent failures (swallowed exceptions and silent sys.exit(0) must say why)
entry: python3 scripts/check_silent_failures.py
@ -236,6 +239,32 @@ repos:
require_serial: true
stages: [pre-push]
# web/ gates. steam-backlog-enforcer, which this app was copied from, has
# only a coverage hook -- its lint and typecheck are unenforced, so both
# can rot without anything failing. All three run here.
- id: web-lint
name: eslint (status web UI)
entry: npm --prefix web run lint
language: system
files: '^web/(src/.*\.(ts|tsx)|eslint\.config\.js|package\.json)$'
pass_filenames: false
require_serial: true
- id: web-typecheck
name: tsc --build (status web UI)
entry: npm --prefix web run build
language: system
files: '^web/(src/.*\.(ts|tsx)|tsconfig.*\.json|vite\.config\.ts)$'
pass_filenames: false
require_serial: true
- id: web-coverage
name: vitest --coverage 100% (status web UI)
entry: npm --prefix web run coverage
language: system
files: '^web/src/'
pass_filenames: false
require_serial: true
stages: [pre-push]
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
# Shared gate in ~/utils owns the namespaces and the exemption list, so

View File

@ -13,6 +13,7 @@ dependencies = [
[project.scripts]
screen-locker-status = "screen_locker.status_view:main"
screen-locker-web = "screen_locker._web_server:main"
# Optional extras. The `mcp` group is ONLY needed by the read-only MCP server
# (screen_locker._mcp), which Claude Code spawns via a dedicated venv (see

19
screen-locker-web.service Normal file
View File

@ -0,0 +1,19 @@
[Unit]
Description=Screen Locker status web UI (localhost only)
# No After=graphical-session.target: this serves HTTP and reads state files, it
# never touches the display. Ordering it after the graphical session would also
# stop it starting on a headless boot, which is exactly when you most want to
# be able to ask whether enforcement is armed.
[Service]
Type=simple
ExecStart=/usr/bin/python3 -m screen_locker._web_server
Restart=always
RestartSec=5s
# steam-backlog-enforcer asks this server whether today has a workout before
# deciding the gaming budget, and fails CLOSED to the smaller budget when the
# answer does not arrive. So a crash costs gaming time rather than silently
# handing it back -- but it should still come straight back up.
[Install]
WantedBy=default.target

View File

@ -0,0 +1,150 @@
"""Whether systemd would actually run the locker, read from systemd itself.
The 2026-08 outage was not a logic bug. Every predicate worked; the timer that
*invokes* them had been disabled by an ordering cycle, and nothing on the
machine considered that worth mentioning. ``systemctl status`` showed no failed
unit, because a job deleted to break a cycle is not a failure -- it is an
absence, and absences do not page anyone.
This module answers one question -- *would enforcement actually happen?* -- from
systemd's own view rather than from the unit files on disk, because the files
were correct-looking the entire time the timer sat disabled.
It lives in the package rather than in ``scripts/`` because two surfaces need
the same answer: ``scripts/check_enforcement_armed.py`` (the gate that rides
along with ``workout-sync.service``) and the Health view of the local web UI.
Two copies of this logic would be free to disagree about whether the locker is
alive, which is the failure it exists to detect.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import shutil
import subprocess
_logger = logging.getLogger(__name__)
# Every unit that must be armed for a workout-less day to end in a lock.
# workout-locker.service itself is WantedBy=graphical-session.target and so is
# only ever a login one-shot; the timers are what make enforcement recur.
REQUIRED_TIMERS = (
"early-bird-workout-check.timer",
"workout-locker.timer",
)
_TIMEOUT_SECONDS = 10
@dataclass(frozen=True)
class TimerState:
"""What systemd currently believes about one timer."""
name: str
enabled: bool
enabled_raw: str
scheduled: bool
@property
def armed(self) -> bool:
"""True only when the timer is both enabled and actually scheduled.
Both halves matter. ``is-enabled`` alone passes for a timer whose job
systemd deleted to break an ordering cycle; ``list-timers`` alone passes
for a transient ``start``ed timer that will not survive a reboot.
Returns:
True when this timer would really fire.
"""
return self.enabled and self.scheduled
def describe(self) -> str:
"""Return a one-line human summary of this timer's state.
Returns:
An ``OK``/``DISARMED`` line naming every reason it is not armed.
"""
if self.armed:
return f"OK {self.name}: enabled and scheduled"
problems = []
if not self.enabled:
problems.append(f"not enabled (is-enabled={self.enabled_raw or 'unknown'})")
if not self.scheduled:
problems.append("no next trigger in list-timers")
return f"DISARMED {self.name}: {', '.join(problems)}"
def systemctl_available() -> bool:
"""Return whether ``systemctl`` can be run at all.
Reachable in containers and CI. Callers must treat False as "did not
check", never as "checked and fine".
Returns:
True when ``systemctl`` is on PATH.
"""
return shutil.which("systemctl") is not None
def _run(args: list[str]) -> tuple[int, str]:
"""Run ``args``, returning ``(returncode, stdout)``. Never raises.
Args:
args: The argument vector, passed without a shell.
Returns:
The process return code and its stdout; ``(1, "")`` if it could not run.
"""
try:
# Explicit argument list, never shell=True.
proc = subprocess.run(
args,
capture_output=True,
text=True,
timeout=_TIMEOUT_SECONDS,
check=False,
)
except OSError, subprocess.SubprocessError:
# A check that cannot run must not look like a check that passed.
_logger.exception("Could not run %s", " ".join(args))
return 1, ""
return proc.returncode, proc.stdout
def _scheduled_timers() -> set[str]:
"""Return the timer names systemd currently has a next trigger for.
Returns:
The subset of :data:`REQUIRED_TIMERS` with a scheduled next run.
"""
_, out = _run(["systemctl", "--user", "list-timers", "--all", "--no-pager"])
scheduled: set[str] = set()
for line in out.splitlines():
for name in REQUIRED_TIMERS:
# A timer with no next trigger prints "n/a" in the NEXT column.
if name in line and " n/a " not in f" {line} ":
scheduled.add(name)
return scheduled
def collect_states() -> list[TimerState]:
"""Return the current arming state of every required timer.
Returns:
One :class:`TimerState` per entry in :data:`REQUIRED_TIMERS`.
"""
scheduled = _scheduled_timers()
states = []
for name in REQUIRED_TIMERS:
code, out = _run(["systemctl", "--user", "is-enabled", name])
raw = out.strip()
states.append(
TimerState(
name=name,
enabled=code == 0 and raw == "enabled",
enabled_raw=raw,
scheduled=name in scheduled,
)
)
return states

View File

@ -52,7 +52,7 @@ from screen_locker._constants import (
)
from screen_locker._sick_tracker import load_history
from screen_locker._status_data import format_summary_line, gather_status
from screen_locker.status_view import _compliance_state_word
from screen_locker._status_projection import compliance_state_word
# Log to STDERR only — STDOUT carries the MCP JSON-RPC protocol frames, so a
# single stray stdout write would corrupt the stream and kill the session.
@ -105,12 +105,12 @@ def get_summary() -> dict[str, Any]:
``summary_line`` is the same string the status bar shows
(``format_summary_line``); ``compliance_state`` is one of ``ok`` / ``warn``
/ ``lock`` (``_compliance_state_word``).
/ ``lock`` (``compliance_state_word``).
"""
snapshot = gather_status()
return {
"summary_line": format_summary_line(snapshot),
"compliance_state": _compliance_state_word(snapshot),
"compliance_state": compliance_state_word(snapshot),
}

View File

@ -108,3 +108,17 @@ def format_summary_line(snapshot: StatusSnapshot) -> str:
f"{mark} {week.counted_count}/{week.minimum} workouts · "
f"{tonight_str} tonight · sick {sick.used_7d}/{sick.budget_7d}"
)
def compliance_state_word(snapshot: StatusSnapshot) -> str:
"""One-word compliance state for the tray icon: ``ok`` / ``warn`` / ``lock``.
``lock`` — the lock would fire right now (no skip condition applies).
``warn`` — not locked, but this week's minimum isn't met yet.
``ok`` — not locked and the weekly minimum is already met.
"""
if snapshot.lock_explanation.fired:
return "lock"
if snapshot.week.remaining > 0:
return "warn"
return "ok"

View File

@ -0,0 +1,209 @@
"""JSON payloads for the local status web UI.
One read-only view, shared by the HTTP server and anything else that wants the
same answer. Deliberately built on :func:`~screen_locker._status_data.gather_status`
rather than on a second walk of the log: ``_mcp`` already proves that snapshot
round-trips through ``json.dumps`` unchanged, and a second projection is exactly
how the status view came to explain a decision the locker never made.
Nothing here reads a secret. The sync token and the HMAC key never appear in a
payload, and no endpoint takes input that reaches the filesystem.
"""
from __future__ import annotations
from dataclasses import asdict
from datetime import UTC, datetime
import logging
from pathlib import Path
from typing import Any
from screen_locker._armed_state import collect_states, systemctl_available
from screen_locker._decision_log import DECISION_LOG_FILE, read_decisions
from screen_locker._log_io import load_workout_log
from screen_locker._status_data import gather_status
from screen_locker._status_projection import (
compliance_state_word,
format_summary_line,
)
from screen_locker._weekly_check import count_day_credits
_logger = logging.getLogger(__name__)
# The workout log lives beside the package, same default as
# ``_status_data._DEFAULT_LOG_FILE``; declared here to avoid importing a
# private name across modules, exactly as ``_mcp`` does.
LOG_FILE = Path(__file__).resolve().parent / "log.json"
# The marker that disables enforcement machine-wide. Kept in sync with
# scripts/disarm_guard.sh, which is the thing that writes it.
DISARM_MARKER = Path.home() / ".local" / "share" / "screen_locker" / "DISARMED"
# How many decisions the Why view gets by default. The trail holds 3000; the
# browser only ever needs the recent tail to answer "why did nothing happen".
DEFAULT_DECISION_LIMIT = 200
def workout_credits_today(*, now: datetime | None = None) -> int:
"""Return how many workout credits today has earned so far.
Uses :func:`~screen_locker._weekly_check.count_day_credits`, the same rule
the locker enforces with, so "did I work out today" cannot drift from "does
the locker think I worked out today".
Args:
now: Override for the current local datetime (for testing).
Returns:
The number of distinct credits earned today, zero if none.
"""
moment = now if now is not None else datetime.now(tz=UTC).astimezone()
today = moment.date().isoformat()
entries = load_workout_log(LOG_FILE).get(today, [])
return count_day_credits(today, [e for e in entries if isinstance(e, dict)])
def build_status_payload(*, now: datetime | None = None) -> dict[str, Any]:
"""Build the full status payload.
Args:
now: Override for the current local datetime (for testing).
Returns:
The status snapshot plus the derived one-liners and the gaming-budget
input the enforcer reads.
"""
snapshot = gather_status()
credits_today = workout_credits_today(now=now)
return {
"snapshot": asdict(snapshot),
"summary_line": format_summary_line(snapshot),
"compliance_state": compliance_state_word(snapshot),
# The gaming budget is decided by steam-backlog-enforcer, which owns the
# hour values. This reports only the fact the decision turns on, so the
# two repos cannot disagree about what a "workout day" is.
"gaming": {
"workout_today": credits_today > 0,
"credits_today": credits_today,
"reason": (
f"{credits_today} counted workout(s) logged today"
if credits_today
else "no counted workout logged today"
),
},
}
def build_decisions_payload(
*,
limit: int = DEFAULT_DECISION_LIMIT,
) -> dict[str, Any]:
"""Build the decision-history payload, newest first.
Args:
limit: Maximum number of decisions to return.
Returns:
The recent decisions and the total the trail holds.
"""
decisions = read_decisions()
tail = decisions[-limit:] if limit > 0 else decisions
return {
"total": len(decisions),
"returned": len(tail),
"decisions": list(reversed(tail)),
}
def _log_age_seconds(*, now: datetime | None = None) -> float | None:
"""Return how long ago the workout log was last written.
Args:
now: Override for the current local datetime (for testing).
Returns:
Age in seconds, or ``None`` when the log does not exist.
"""
try:
mtime = LOG_FILE.stat().st_mtime
except OSError:
_logger.warning(
"Cannot stat the workout log at %s — reporting its age as unknown "
"rather than as fresh",
LOG_FILE,
)
return None
moment = now if now is not None else datetime.now(tz=UTC).astimezone()
return moment.timestamp() - mtime
def _last_decision_age_seconds(*, now: datetime | None = None) -> float | None:
"""Return how long ago a decision was last recorded.
A locker that stopped running goes quiet rather than failing, so the age of
the newest decision is the most direct evidence that it is still alive.
Args:
now: Override for the current local datetime (for testing).
Returns:
Age in seconds, or ``None`` when nothing has been recorded.
"""
decisions = read_decisions()
if not decisions:
return None
stamp = decisions[-1].get("timestamp")
if not isinstance(stamp, str):
return None
try:
recorded = datetime.fromisoformat(stamp)
except ValueError:
_logger.warning(
"Newest decision has an unparsable timestamp %r — reporting its "
"age as unknown rather than as recent",
stamp,
)
return None
moment = now if now is not None else datetime.now(tz=UTC).astimezone()
return moment.timestamp() - recorded.timestamp()
def build_health_payload(*, now: datetime | None = None) -> dict[str, Any]:
"""Build the health payload: is the locker actually able to fire.
Every field here exists because its absence once went unnoticed. A timer
silently deleted to break an ordering cycle, a disarm marker left in place,
a log that stopped being written -- none of those raise an error anywhere,
so each is reported as a value rather than left to be inferred from silence.
Args:
now: Override for the current local datetime (for testing).
Returns:
Timer arming state, the disarm marker, and freshness of log and trail.
"""
checked = systemctl_available()
timers = [
{
"name": state.name,
"enabled": state.enabled,
"scheduled": state.scheduled,
"armed": state.armed,
"describe": state.describe(),
}
for state in (collect_states() if checked else ())
]
disarmed = DISARM_MARKER.exists()
return {
# "could not check" is reported as its own state: it must never be
# rendered as a green tick.
"timers_checked": checked,
"timers": timers,
"armed": checked and not disarmed and all(t["armed"] for t in timers),
"disarmed": disarmed,
"disarm_marker": str(DISARM_MARKER),
"log_file": str(LOG_FILE),
"log_age_seconds": _log_age_seconds(now=now),
"decision_log_file": str(DECISION_LOG_FILE),
"last_decision_age_seconds": _last_decision_age_seconds(now=now),
}

View File

@ -0,0 +1,222 @@
"""Read-only localhost HTTP server for the status web UI.
Serves the status/decision/health payloads at ``/api/*`` and the built React
bundle (``web/dist``) as static files. Binds to loopback only and never exposes
a secret: every payload comes from :mod:`screen_locker._web_payload`, which
reads state files but never the sync token or the HMAC key.
Read-only by construction, like the MCP server it sits beside: there is no
route that logs a workout, clears a lock, or writes anything at all. A local
web page that could log a workout would be a hole straight through the gate.
Two things depend on this process being up: the browser UI, and
steam-backlog-enforcer, which asks ``/api/status`` whether today has a workout
before deciding the gaming budget. The enforcer fails closed to the smaller
budget when this is unreachable, so a dead server costs gaming time rather than
silently handing it back.
"""
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 typing import TYPE_CHECKING, Any
from urllib.parse import parse_qs, urlsplit
from screen_locker._web_payload import (
DEFAULT_DECISION_LIMIT,
build_decisions_payload,
build_health_payload,
build_status_payload,
)
if TYPE_CHECKING:
from collections.abc import Callable
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"
# 8000 is taken by steam-backlog-enforcer's own web UI.
DEFAULT_PORT = 8770
_API_STATUS = "/api/status"
_API_DECISIONS = "/api/decisions"
_API_HEALTH = "/api/health"
# 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: npm --prefix web install && npm --prefix web run build"
)
def _decision_limit(query: str) -> int:
"""Parse ``?limit=`` from a query string, falling back to the default.
Args:
query: The raw query string.
Returns:
A positive limit; the default when absent, non-numeric or out of range.
"""
raw = parse_qs(query).get("limit", [""])[0]
try:
value = int(raw)
except ValueError:
if raw:
# Benign (a hand-typed URL), but say so: a silently ignored
# parameter is how someone concludes the endpoint is broken.
logger.warning(
"Ignoring unparsable ?limit=%r; serving the default %d decisions",
raw,
DEFAULT_DECISION_LIMIT,
)
return DEFAULT_DECISION_LIMIT
return value if value > 0 else DEFAULT_DECISION_LIMIT
class _Handler(BaseHTTPRequestHandler):
"""Serve the status payloads 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.
Args:
fmt: printf-style format string from BaseHTTPRequestHandler.
args: Its arguments.
"""
logger.debug("%s - %s", self.address_string(), fmt % args)
def do_GET(self) -> None:
"""Dispatch a GET to one of the APIs or to a static file."""
split = urlsplit(self.path)
if split.path == _API_STATUS:
self._serve_json("status", build_status_payload)
elif split.path == _API_DECISIONS:
limit = _decision_limit(split.query)
self._serve_json("decisions", lambda: build_decisions_payload(limit=limit))
elif split.path == _API_HEALTH:
self._serve_json("health", build_health_payload)
else:
self._serve_static(split.path)
def _serve_json(self, name: str, build: Callable[[], dict[str, Any]]) -> None:
"""Build a payload and send it as JSON, or a 500 naming the failure.
Args:
name: Payload name, used in the error message and the log.
build: Zero-argument callable returning the payload.
"""
try:
body = json.dumps(build()).encode("utf-8")
except OSError, ValueError, KeyError, TypeError:
# Never a bare swallow: the reason has to reach the journal, or a
# broken endpoint looks identical to an endpoint with nothing to say.
logger.exception("Failed to build the %s payload", name)
self._send(
HTTPStatus.INTERNAL_SERVER_ERROR,
f"{name} error - see the journal for screen-locker-web".encode(),
"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.
Args:
path: The request path.
"""
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.
Args:
status: HTTP status to send.
body: Response body.
ctype: Content type, charset appended for text types.
"""
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.
Args:
host: Address to bind. Must stay on loopback: the API is unauthenticated.
port: Port to bind.
Returns:
The unstarted server.
Raises:
ValueError: If asked to bind anywhere but loopback.
"""
if not host.startswith("127.") and host != "localhost":
# The payloads are unauthenticated, so a bind must never leave this
# machine. Refuse loudly rather than quietly publishing them.
msg = f"Refusing to bind {host}: the status API is loopback-only"
raise ValueError(msg)
return ThreadingHTTPServer((host, port), _Handler)
def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
"""Run the web server until interrupted.
Args:
host: Address to bind.
port: Port to bind.
"""
server = create_server(host, port)
logger.info("Screen locker status UI: http://%s:%d", host, port)
try:
server.serve_forever()
except KeyboardInterrupt:
# Warning, not info: this process is load-bearing for the gaming
# budget, so its shutdown must be visible in the journal.
logger.warning("Interrupted — shutting the status server down.")
finally:
server.server_close()
def main() -> int:
"""Console-script entry point for ``screen-locker-web``.
Returns:
Process exit code.
"""
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
serve()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -37,6 +37,7 @@ from screen_locker._status_data import (
format_summary_line,
gather_status,
)
from screen_locker._status_projection import compliance_state_word
from screen_locker._status_sections import StatusSectionsMixin
from screen_locker._status_view_verify import PhoneCheckMixin, _make_bare_verifier
from screen_locker._surface_group import FrameGroup
@ -199,20 +200,6 @@ class StatusWindow(
self.render(gather_status())
def _compliance_state_word(snapshot: StatusSnapshot) -> str:
"""One-word compliance state for the tray icon: ``ok`` / ``warn`` / ``lock``.
``lock`` — the lock would fire right now (no skip condition applies).
``warn`` — not locked, but this week's minimum isn't met yet.
``ok`` — not locked and the weekly minimum is already met.
"""
if snapshot.lock_explanation.fired:
return "lock"
if snapshot.week.remaining > 0:
return "warn"
return "ok"
def main(argv: list[str] | None = None) -> None:
"""Entry point: ``--summary``/``--state``/``--sync`` print one line.
@ -223,7 +210,7 @@ def main(argv: list[str] | None = None) -> None:
print(format_summary_line(gather_status()))
return
if "--state" in args:
print(_compliance_state_word(gather_status()))
print(compliance_state_word(gather_status()))
return
if "--sync" in args:
# On-disk only, like the other two -- safe on an i3blocks tick.

View File

@ -0,0 +1,135 @@
"""Tests for _armed_state: is the locker actually schedulable.
The distinction these tests defend is the one that cost thirteen days in
2026-08: ``is-enabled`` says "enabled" for a timer whose job systemd deleted to
break an ordering cycle, so enabled alone is not armed.
"""
from __future__ import annotations
import subprocess
from unittest.mock import MagicMock, patch
from screen_locker._armed_state import (
REQUIRED_TIMERS,
TimerState,
collect_states,
systemctl_available,
)
_PKG = "screen_locker._armed_state"
_LISTED = """NEXT LEFT LAST PASSED UNIT
Sun 2026-08-30 08:30:00 CEST 10h - - early-bird-workout-check.timer
Sat 2026-08-29 23:00:00 CEST 30min - - workout-locker.timer
"""
def _completed(stdout: str, code: int = 0) -> MagicMock:
"""Build a stand-in for a finished subprocess.
Args:
stdout: What the command printed.
code: Its return code.
Returns:
An object with the two attributes ``_run`` reads.
"""
proc = MagicMock()
proc.returncode = code
proc.stdout = stdout
return proc
class TestTimerState:
"""armed is the conjunction; describe() names every missing half."""
def test_enabled_and_scheduled_is_armed(self) -> None:
"""Both halves present means the timer will really fire."""
state = TimerState("t", enabled=True, enabled_raw="enabled", scheduled=True)
assert state.armed is True
assert "OK" in state.describe()
def test_enabled_but_unscheduled_is_not_armed(self) -> None:
"""The exact 2026-08 failure: enabled, but the job was deleted."""
state = TimerState("t", enabled=True, enabled_raw="enabled", scheduled=False)
assert state.armed is False
assert "no next trigger" in state.describe()
def test_scheduled_but_disabled_is_not_armed(self) -> None:
"""A transient start does not survive a reboot."""
state = TimerState("t", enabled=False, enabled_raw="disabled", scheduled=True)
assert state.armed is False
assert "not enabled (is-enabled=disabled)" in state.describe()
def test_unknown_enabled_state_is_named(self) -> None:
"""An empty is-enabled reads as unknown, not as a blank."""
state = TimerState("t", enabled=False, enabled_raw="", scheduled=False)
assert "is-enabled=unknown" in state.describe()
class TestSystemctlAvailable:
""" "Could not check" must be distinguishable from "checked and fine"."""
def test_present(self) -> None:
"""systemctl on PATH means the check can run."""
with patch(f"{_PKG}.shutil.which", return_value="/usr/bin/systemctl"):
assert systemctl_available() is True
def test_absent(self) -> None:
"""No systemctl (containers, CI) is reported, never assumed fine."""
with patch(f"{_PKG}.shutil.which", return_value=None):
assert systemctl_available() is False
class TestCollectStates:
"""collect_states reads systemd's own view, not the unit files."""
def test_all_armed(self) -> None:
"""Both timers enabled and listed with a next trigger."""
with patch(f"{_PKG}.subprocess.run") as run:
run.side_effect = [
_completed(_LISTED),
*[_completed("enabled\n") for _ in REQUIRED_TIMERS],
]
states = collect_states()
assert [s.name for s in states] == list(REQUIRED_TIMERS)
assert all(s.armed for s in states)
def test_timer_listed_with_no_next_trigger_is_unscheduled(self) -> None:
"""An "n/a" NEXT column means systemd will never run it."""
listed = (
"NEXT LEFT LAST PASSED UNIT\n n/a - - - workout-locker.timer\n"
)
with patch(f"{_PKG}.subprocess.run") as run:
run.side_effect = [
_completed(listed),
*[_completed("enabled\n") for _ in REQUIRED_TIMERS],
]
states = collect_states()
assert all(not s.scheduled for s in states)
def test_disabled_timer_is_reported(self) -> None:
"""A non-zero is-enabled marks the timer disabled."""
with patch(f"{_PKG}.subprocess.run") as run:
run.side_effect = [
_completed(_LISTED),
*[_completed("disabled\n", code=1) for _ in REQUIRED_TIMERS],
]
states = collect_states()
assert all(not s.enabled for s in states)
def test_a_check_that_cannot_run_is_not_a_pass(self) -> None:
"""When systemctl cannot be executed, nothing reads as armed."""
with patch(f"{_PKG}.subprocess.run", side_effect=OSError("no exec")):
states = collect_states()
assert all(not s.armed for s in states)
def test_a_timeout_is_not_a_pass_either(self) -> None:
"""A hung systemctl must fail closed, same as a missing one."""
with patch(
f"{_PKG}.subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="systemctl", timeout=10),
):
states = collect_states()
assert all(not s.armed for s in states)

View File

@ -103,7 +103,7 @@ class TestReadTools:
with (
patch.object(_mcp, "gather_status") as gs,
patch.object(_mcp, "format_summary_line", return_value="LINE") as fsl,
patch.object(_mcp, "_compliance_state_word", return_value="ok") as csw,
patch.object(_mcp, "compliance_state_word", return_value="ok") as csw,
):
out = _mcp.get_summary()
assert out == {"summary_line": "LINE", "compliance_state": "ok"}

View File

@ -2,9 +2,7 @@
from __future__ import annotations
from screen_locker.status_view import (
_compliance_state_word,
)
from screen_locker._status_projection import compliance_state_word
from screen_locker.tests._status_view_helpers import (
_lock_explanation,
_snapshot,
@ -18,7 +16,7 @@ class TestComplianceStateWord:
def test_lock_when_fired(self) -> None:
"""Lock when fired."""
snap = _snapshot(lock_explanation=_lock_explanation(fired=True))
assert _compliance_state_word(snap) == "lock"
assert compliance_state_word(snap) == "lock"
def test_warn_when_not_fired_but_remaining(self) -> None:
"""Warn when not fired but remaining."""
@ -26,7 +24,7 @@ class TestComplianceStateWord:
lock_explanation=_lock_explanation(fired=False),
week=_week(remaining=2),
)
assert _compliance_state_word(snap) == "warn"
assert compliance_state_word(snap) == "warn"
def test_ok_when_not_fired_and_minimum_met(self) -> None:
"""Ok when not fired and minimum met."""
@ -34,4 +32,4 @@ class TestComplianceStateWord:
lock_explanation=_lock_explanation(fired=False),
week=_week(remaining=0),
)
assert _compliance_state_word(snap) == "ok"
assert compliance_state_word(snap) == "ok"

View File

@ -0,0 +1,144 @@
"""Tests for _web_payload: the JSON the UI and the enforcer both read.
The load-bearing assertion here is that ``gaming.workout_today`` comes from the
same credit rule the locker enforces with. steam-backlog-enforcer cuts the
gaming budget on that field, so if it ever drifted from
``count_day_credits`` the two repos would disagree about what a workout day is.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from screen_locker._web_payload import (
build_decisions_payload,
build_status_payload,
workout_credits_today,
)
if TYPE_CHECKING:
from pathlib import Path
_PKG = "screen_locker._web_payload"
_SATURDAY = datetime(2026, 8, 29, 21, 0, 0, tzinfo=UTC)
def _entry(wtype: str) -> dict[str, Any]:
"""Build a log entry of the given type.
Args:
wtype: The ``workout_data.type`` value.
Returns:
A log entry dict.
"""
return {"timestamp": "2026-08-29T10:00:00+00:00", "workout_data": {"type": wtype}}
def _with_log(entries: dict[str, list[dict[str, Any]]], tmp_path: Path) -> Any:
"""Patch the module's LOG_FILE at a temporary log holding ``entries``.
Args:
entries: Date-keyed log contents.
tmp_path: pytest temporary directory.
Returns:
A patch context manager for the module-level LOG_FILE.
"""
log = tmp_path / "log.json"
log.write_text(json.dumps(entries), encoding="utf-8")
return patch(f"{_PKG}.LOG_FILE", log)
class TestWorkoutCreditsToday:
"""Today's credits come from the enforcement rule, not a second count."""
def test_no_entries_is_zero(self, tmp_path: Path) -> None:
"""An untouched day earns nothing."""
with _with_log({}, tmp_path):
assert workout_credits_today(now=_SATURDAY) == 0
def test_a_workout_counts(self, tmp_path: Path) -> None:
"""A verified workout today earns a credit."""
with _with_log({"2026-08-29": [_entry("phone_verified")]}, tmp_path):
assert workout_credits_today(now=_SATURDAY) == 1
def test_the_stronglifts_pair_still_counts_once(self, tmp_path: Path) -> None:
"""Both ingestion paths for one session share today's credit too."""
entries = {
"2026-08-29": [_entry("phone_verified"), _entry("pc_workout_verified")],
}
with _with_log(entries, tmp_path):
assert workout_credits_today(now=_SATURDAY) == 1
def test_a_relaxed_day_skip_earns_nothing(self, tmp_path: Path) -> None:
"""Dismissing the popup is not a workout — so it is a 6h gaming day."""
with _with_log({"2026-08-29": [_entry("relaxed_day_skip")]}, tmp_path):
assert workout_credits_today(now=_SATURDAY) == 0
def test_yesterdays_workout_does_not_count_today(self, tmp_path: Path) -> None:
"""The budget is per gaming day, so only today's entries count."""
with _with_log({"2026-08-28": [_entry("phone_verified")]}, tmp_path):
assert workout_credits_today(now=_SATURDAY) == 0
def test_non_dict_entries_are_ignored(self, tmp_path: Path) -> None:
"""A malformed entry must not crash the endpoint."""
log = tmp_path / "log.json"
log.write_text(json.dumps({"2026-08-29": ["junk"]}), encoding="utf-8")
with patch(f"{_PKG}.LOG_FILE", log):
assert workout_credits_today(now=_SATURDAY) == 0
class TestStatusPayload:
"""The status payload is JSON-safe and carries the gaming fact."""
def test_no_workout_today_reads_as_a_cut_budget(self, tmp_path: Path) -> None:
"""workout_today False is what makes the enforcer apply 6h."""
with _with_log({}, tmp_path):
payload = build_status_payload(now=_SATURDAY)
assert payload["gaming"]["workout_today"] is False
assert "no counted workout" in payload["gaming"]["reason"]
def test_a_workout_today_reads_as_earned(self, tmp_path: Path) -> None:
"""A logged workout restores the full budget."""
with _with_log({"2026-08-29": [_entry("phone_verified")]}, tmp_path):
payload = build_status_payload(now=_SATURDAY)
assert payload["gaming"]["workout_today"] is True
assert payload["gaming"]["credits_today"] == 1
def test_payload_round_trips_through_json(self, tmp_path: Path) -> None:
"""No dataclass or datetime may leak into the HTTP body."""
with _with_log({}, tmp_path):
payload = build_status_payload(now=_SATURDAY)
assert json.loads(json.dumps(payload))["summary_line"]
def test_payload_carries_the_derived_one_liners(self, tmp_path: Path) -> None:
"""The i3blocks line and the tray word come from the same snapshot."""
with _with_log({}, tmp_path):
payload = build_status_payload(now=_SATURDAY)
assert payload["compliance_state"] in {"ok", "warn", "lock"}
assert "snapshot" in payload
class TestDecisionsPayload:
"""The Why view gets a bounded, newest-first slice of the trail."""
def test_newest_first(self) -> None:
"""The browser reads top-down; the trail is stored oldest-first."""
trail = [{"timestamp": "1"}, {"timestamp": "2"}, {"timestamp": "3"}]
with patch(f"{_PKG}.read_decisions", return_value=trail):
payload = build_decisions_payload(limit=2)
assert [d["timestamp"] for d in payload["decisions"]] == ["3", "2"]
assert payload["total"] == 3
assert payload["returned"] == 2
def test_a_non_positive_limit_returns_everything(self) -> None:
"""Guards the slice: limit=0 must not silently mean "no history"."""
trail = [{"timestamp": "1"}, {"timestamp": "2"}]
with patch(f"{_PKG}.read_decisions", return_value=trail):
payload = build_decisions_payload(limit=0)
assert payload["returned"] == 2

View File

@ -0,0 +1,138 @@
"""Tests for _web_payload.build_health_payload.
Split out of test_web_payload.py for the 250-line cap.
Every field asserted here exists because its absence once went unnoticed: a
timer systemd deleted to break an ordering cycle, a disarm marker nobody
removed, a log that quietly stopped being written. The rule the tests enforce
is that "could not check" is its own state and never reads as a pass.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from unittest.mock import patch
from screen_locker._armed_state import TimerState
from screen_locker._web_payload import build_health_payload
if TYPE_CHECKING:
from pathlib import Path
_PKG = "screen_locker._web_payload"
_SATURDAY = datetime(2026, 8, 29, 21, 0, 0, tzinfo=UTC)
class TestHealthPayload:
"""Health turns every silent absence into a rendered value."""
def _states(self, *, armed: bool) -> list[TimerState]:
"""Build timer states for the fake.
Args:
armed: Whether the timers should read as armed.
Returns:
One state per required timer.
"""
return [
TimerState("workout-locker.timer", armed, "enabled", armed),
]
def test_armed_when_everything_checks_out(self, tmp_path: Path) -> None:
"""Timers armed, no marker, systemctl present."""
with (
patch(f"{_PKG}.systemctl_available", return_value=True),
patch(f"{_PKG}.collect_states", return_value=self._states(armed=True)),
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
):
payload = build_health_payload(now=_SATURDAY)
assert payload["armed"] is True
assert payload["timers"][0]["armed"] is True
def test_unchecked_is_never_armed(self, tmp_path: Path) -> None:
"""Without systemctl the answer is unknown, which is not a pass."""
with (
patch(f"{_PKG}.systemctl_available", return_value=False),
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
):
payload = build_health_payload(now=_SATURDAY)
assert payload["timers_checked"] is False
assert payload["armed"] is False
assert payload["timers"] == []
def test_disarm_marker_overrides_armed_timers(self, tmp_path: Path) -> None:
"""The marker is the deliberate off switch; it must win."""
marker = tmp_path / "DISARMED"
marker.write_text("off for a reason", encoding="utf-8")
with (
patch(f"{_PKG}.systemctl_available", return_value=True),
patch(f"{_PKG}.collect_states", return_value=self._states(armed=True)),
patch(f"{_PKG}.DISARM_MARKER", marker),
):
payload = build_health_payload(now=_SATURDAY)
assert payload["disarmed"] is True
assert payload["armed"] is False
def test_missing_log_reports_unknown_age(self, tmp_path: Path) -> None:
"""A log that is not there reads as unknown, not as fresh."""
with (
patch(f"{_PKG}.systemctl_available", return_value=False),
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
patch(f"{_PKG}.LOG_FILE", tmp_path / "missing.json"),
patch(f"{_PKG}.read_decisions", return_value=[]),
):
payload = build_health_payload(now=_SATURDAY)
assert payload["log_age_seconds"] is None
assert payload["last_decision_age_seconds"] is None
def test_decision_age_is_measured_from_the_newest_entry(
self,
tmp_path: Path,
) -> None:
"""A quiet locker shows up as an old last decision."""
trail = [{"timestamp": "2026-08-29T20:00:00+00:00"}]
with (
patch(f"{_PKG}.systemctl_available", return_value=False),
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
patch(f"{_PKG}.read_decisions", return_value=trail),
):
payload = build_health_payload(now=_SATURDAY)
assert payload["last_decision_age_seconds"] == 3600
def test_a_corrupt_timestamp_reads_as_unknown(self, tmp_path: Path) -> None:
"""An unparsable stamp must not crash the Health view."""
with (
patch(f"{_PKG}.systemctl_available", return_value=False),
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
patch(f"{_PKG}.read_decisions", return_value=[{"timestamp": "nonsense"}]),
):
payload = build_health_payload(now=_SATURDAY)
assert payload["last_decision_age_seconds"] is None
def test_a_missing_timestamp_reads_as_unknown(self, tmp_path: Path) -> None:
"""A decision line without a timestamp is handled, not assumed."""
with (
patch(f"{_PKG}.systemctl_available", return_value=False),
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
patch(f"{_PKG}.read_decisions", return_value=[{"reason": "enforced"}]),
):
payload = build_health_payload(now=_SATURDAY)
assert payload["last_decision_age_seconds"] is None
def test_log_age_is_measured_from_mtime(self, tmp_path: Path) -> None:
"""A written log reports a real, non-negative age."""
log = tmp_path / "log.json"
log.write_text("{}", encoding="utf-8")
with (
patch(f"{_PKG}.systemctl_available", return_value=False),
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
patch(f"{_PKG}.LOG_FILE", log),
patch(f"{_PKG}.read_decisions", return_value=[]),
):
payload = build_health_payload()
age = payload["log_age_seconds"]
assert isinstance(age, float)
assert age >= 0

View File

@ -0,0 +1,209 @@
"""Tests for _web_server: routing, static serving, and the loopback guard.
The server is started on an ephemeral port and driven over real HTTP rather
than by calling handler methods directly, so the response codes and content
types tested here are the ones a browser and the enforcer actually see.
"""
from __future__ import annotations
from contextlib import contextmanager
from http.client import HTTPConnection
import json
import socket
import threading
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from screen_locker._web_server import _decision_limit, create_server, main, serve
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_PKG = "screen_locker._web_server"
# conftest's autouse _block_real_network patches
# "screen_locker._http_workout_fetch.socket.create_connection", and that
# attribute path resolves to the *global* socket module -- so every test in the
# suite has socket.create_connection stubbed, including loopback connections to
# a server we started ourselves. Captured at import time, before any fixture
# runs, which is the only moment the real function is still reachable. The
# fixture's own docstring says tests needing a real probe patch it locally.
_REAL_CREATE_CONNECTION = socket.create_connection
@pytest.fixture
def real_loopback(monkeypatch: pytest.MonkeyPatch) -> None:
"""Restore real socket connections for tests that drive the server.
Args:
monkeypatch: pytest's attribute patcher, which undoes this on teardown.
"""
monkeypatch.setattr(socket, "create_connection", _REAL_CREATE_CONNECTION)
@contextmanager
def _running() -> Iterator[int]:
"""Start the server on an ephemeral port in a thread; yield the port.
Yields:
The bound 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)``.
Args:
port: Port to connect to.
path: Request path.
Returns:
The status code, body bytes and Content-Type header.
"""
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) -> Path:
"""Create a fake built-frontend directory.
Args:
tmp_path: pytest temporary directory.
with_index: Whether to write an ``index.html``.
Returns:
The resolved dist directory.
"""
dist = (tmp_path / "dist").resolve()
dist.mkdir()
if with_index:
(dist / "index.html").write_text("<html>INDEX</html>", encoding="utf-8")
(dist / "app.css").write_text("body{}", encoding="utf-8")
(dist / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\n")
return dist
class TestDecisionLimit:
"""?limit= is parsed defensively; a bad value never reaches the reader."""
def test_absent_limit_uses_the_default(self) -> None:
"""No query string means the default page size."""
assert _decision_limit("") > 0
def test_explicit_limit_is_honoured(self) -> None:
"""A sane limit passes through."""
assert _decision_limit("limit=5") == 5
def test_non_numeric_limit_falls_back(self) -> None:
"""Garbage in the query string must not 500 the endpoint."""
assert _decision_limit("limit=abc") == _decision_limit("")
def test_zero_or_negative_limit_falls_back(self) -> None:
"""A limit of zero would render an empty history for no reason."""
assert _decision_limit("limit=0") == _decision_limit("")
class TestApiRoutes:
"""Each API path serves its own payload as JSON."""
@pytest.fixture(autouse=True)
def _network(self, real_loopback: None) -> None:
"""Every test in this class talks to the server over real HTTP."""
def test_status_route(self) -> None:
"""/api/status serves the status payload."""
with (
patch(f"{_PKG}.build_status_payload", return_value={"ok": True}),
_running() as port,
):
status, body, ctype = _get(port, "/api/status")
assert status == 200
assert json.loads(body) == {"ok": True}
assert ctype.startswith("application/json")
def test_health_route(self) -> None:
"""/api/health serves the health payload."""
with (
patch(f"{_PKG}.build_health_payload", return_value={"armed": False}),
_running() as port,
):
status, body, _ = _get(port, "/api/health")
assert status == 200
assert json.loads(body) == {"armed": False}
def test_decisions_route_passes_the_limit_through(self) -> None:
"""/api/decisions forwards ?limit= to the payload builder."""
with (
patch(
f"{_PKG}.build_decisions_payload", return_value={"total": 0}
) as build,
_running() as port,
):
status, _, _ = _get(port, "/api/decisions?limit=7")
assert status == 200
build.assert_called_once_with(limit=7)
def test_a_broken_payload_returns_500_and_says_which(self) -> None:
"""A failing builder must never look like an endpoint with no news."""
with (
patch(f"{_PKG}.build_status_payload", side_effect=OSError("disk gone")),
_running() as port,
):
status, body, _ = _get(port, "/api/status")
assert status == 500
assert b"status error" in body
class TestBindGuard:
"""The API is unauthenticated, so it may only ever bind loopback."""
@pytest.mark.parametrize("host", ["127.0.0.1", "localhost"])
def test_loopback_is_allowed(self, host: str) -> None:
"""Both loopback spellings are accepted."""
server = create_server(host, 0)
server.server_close()
def test_a_public_bind_is_refused(self) -> None:
"""A LAN address would publish the payloads beyond this machine."""
with pytest.raises(ValueError, match="loopback-only"):
create_server("192.0.2.1", 0)
class TestServeEntryPoints:
"""serve() and main() wire the server to the process lifetime."""
def test_serve_closes_the_socket_on_interrupt(self) -> None:
"""Ctrl-C shuts down cleanly rather than leaving the port bound."""
server = create_server("127.0.0.1", 0)
with (
patch(f"{_PKG}.create_server", return_value=server),
patch.object(server, "serve_forever", side_effect=KeyboardInterrupt),
patch.object(server, "server_close") as close,
):
serve()
close.assert_called_once()
server.server_close()
def test_main_returns_zero(self) -> None:
"""The console-script entry point reports success on a clean exit."""
with patch(f"{_PKG}.serve") as served:
assert main() == 0
served.assert_called_once()

View File

@ -0,0 +1,81 @@
"""Tests for _web_server static serving: SPA fallback and traversal guard.
Split out of test_web_server.py for the 250-line cap; the server harness and
the real_loopback fixture stay in that module and are imported here.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from screen_locker.tests.test_web_server import (
_PKG,
_get,
_make_dist,
_running,
real_loopback,
)
if TYPE_CHECKING:
from pathlib import Path
__all__ = ["real_loopback"]
class TestStaticServing:
"""The built bundle is served with an SPA fallback and a traversal guard."""
@pytest.fixture(autouse=True)
def _network(self, real_loopback: None) -> None:
"""Every test in this class talks to the server over real HTTP."""
def test_index_is_served_at_the_root(self, tmp_path: Path) -> None:
"""/ serves index.html."""
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
status, body, ctype = _get(port, "/")
assert status == 200
assert b"INDEX" in body
assert ctype.startswith("text/html")
def test_an_asset_is_served_with_its_own_type(self, tmp_path: Path) -> None:
"""A real file is served rather than the SPA fallback."""
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
status, body, ctype = _get(port, "/app.css")
assert status == 200
assert body == b"body{}"
assert ctype.startswith("text/css")
def test_a_binary_asset_gets_no_charset(self, tmp_path: Path) -> None:
"""A charset on image/png would be wrong; only text types get one."""
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
status, body, ctype = _get(port, "/icon.png")
assert status == 200
assert body.startswith(b"\x89PNG")
assert ctype == "image/png"
def test_an_unknown_route_falls_back_to_index(self, tmp_path: Path) -> None:
"""SPA routes are handled by the bundle, not by the server."""
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
status, body, _ = _get(port, "/why/2026-08-29")
assert status == 200
assert b"INDEX" in body
def test_traversal_never_escapes_the_bundle(self, tmp_path: Path) -> None:
"""A ../ path is refused, not resolved against the filesystem."""
secret = tmp_path / "secret.txt"
secret.write_text("do not serve me", encoding="utf-8")
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
status, body, _ = _get(port, "/../secret.txt")
assert status == 200
assert b"do not serve me" not in body
def test_missing_bundle_says_how_to_build_it(self, tmp_path: Path) -> None:
"""404 carries the build command instead of an empty page."""
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"npm --prefix web run build" in body

View File

@ -28,109 +28,23 @@ Usage:
from __future__ import annotations
import argparse
from dataclasses import dataclass
import logging
import shutil
import subprocess
import sys
from screen_locker._armed_state import collect_states, systemctl_available
_logger = logging.getLogger(__name__)
# Every unit that must be armed for a workout-less day to end in a lock.
# workout-locker.service itself is WantedBy=graphical-session.target and so is
# only ever a login one-shot; the timers are what make enforcement recur.
REQUIRED_TIMERS = (
"early-bird-workout-check.timer",
"workout-locker.timer",
)
_TIMEOUT_SECONDS = 10
@dataclass(frozen=True)
class TimerState:
"""What systemd currently believes about one timer."""
name: str
enabled: bool
enabled_raw: str
scheduled: bool
@property
def armed(self) -> bool:
"""True only when the timer is both enabled and actually scheduled.
Both halves matter. ``is-enabled`` alone passes for a timer whose job
systemd deleted to break an ordering cycle; ``list-timers`` alone passes
for a transient ``start``ed timer that will not survive a reboot.
"""
return self.enabled and self.scheduled
def describe(self) -> str:
"""Return a one-line human summary of this timer's state."""
if self.armed:
return f"OK {self.name}: enabled and scheduled"
problems = []
if not self.enabled:
problems.append(f"not enabled (is-enabled={self.enabled_raw or 'unknown'})")
if not self.scheduled:
problems.append("no next trigger in list-timers")
return f"DISARMED {self.name}: {', '.join(problems)}"
def _report(message: str) -> None:
"""Write one line to stderr, so a disarmed locker is visible in the unit."""
"""Write one line to stderr, so a disarmed locker is visible in the unit.
Args:
message: The line to write.
"""
sys.stderr.write(f"{message}\n")
def _run(args: list[str]) -> tuple[int, str]:
"""Run ``args``, returning (returncode, stdout). Never raises."""
try:
# Explicit argument list, never shell=True.
proc = subprocess.run(
args,
capture_output=True,
text=True,
timeout=_TIMEOUT_SECONDS,
check=False,
)
except OSError, subprocess.SubprocessError:
# A check that cannot run must not look like a check that passed.
_logger.exception("Could not run %s", " ".join(args))
return 1, ""
return proc.returncode, proc.stdout
def _scheduled_timers() -> set[str]:
"""Return the timer names systemd currently has a next trigger for."""
_, out = _run(["systemctl", "--user", "list-timers", "--all", "--no-pager"])
scheduled: set[str] = set()
for line in out.splitlines():
for name in REQUIRED_TIMERS:
# A timer with no next trigger prints "n/a" in the NEXT column.
if name in line and " n/a " not in f" {line} ":
scheduled.add(name)
return scheduled
def collect_states() -> list[TimerState]:
"""Return the current arming state of every required timer."""
scheduled = _scheduled_timers()
states = []
for name in REQUIRED_TIMERS:
code, out = _run(["systemctl", "--user", "is-enabled", name])
raw = out.strip()
states.append(
TimerState(
name=name,
enabled=code == 0 and raw == "enabled",
enabled_raw=raw,
scheduled=name in scheduled,
)
)
return states
def main(argv: list[str] | None = None) -> int:
"""Report arming state; return 1 when enforcement would not happen."""
parser = argparse.ArgumentParser(description=__doc__)
@ -141,7 +55,7 @@ def main(argv: list[str] | None = None) -> int:
)
args = parser.parse_args(argv)
if shutil.which("systemctl") is None:
if not systemctl_available():
# Reachable in containers/CI. Say so explicitly rather than passing:
# "could not check" must never be recorded as "checked and fine".
_logger.error("systemctl not found — the arming check could not run")

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

@ -0,0 +1,32 @@
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'
// Deliberately stricter than steam-backlog-enforcer/web, which this was copied
// from: that one extends only `tseslint.configs.recommended` and so runs no
// type-aware rules at all. `strictTypeChecked` needs `projectService`, which is
// why tsconfig.test.json exists — test files are outside tsconfig.app.json's
// program and would otherwise be unlintable.
export default defineConfig([
globalIgnores(['dist', 'coverage']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.strictTypeChecked,
tseslint.configs.stylisticTypeChecked,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
])

12
web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Screen Locker — status</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4046
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
web/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "screen-locker-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit false --emitDeclarationOnly false",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.6",
"@types/node": "26.4.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "4.1.11",
"eslint": "10.9.1",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.5",
"globals": "17.11.0",
"jsdom": "30.0.1",
"typescript": "6.0.3",
"typescript-eslint": "8.68.0",
"vite": "8.2.2",
"vitest": "4.1.11"
}
}

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

@ -0,0 +1,53 @@
import { render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { App } from './App'
import { makeDecisions, makeHealth, makeStatus } from './test/factories'
function stubEndpoints(overrides: Record<string, unknown> = {}): void {
vi.stubGlobal(
'fetch',
vi.fn((url: string) => {
const body =
url.startsWith('/api/status')
? (overrides.status ?? makeStatus())
: url.startsWith('/api/health')
? (overrides.health ?? makeHealth())
: (overrides.decisions ?? makeDecisions())
return Promise.resolve({ ok: true, json: () => Promise.resolve(body) })
}),
)
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('App', () => {
it('renders all three views once the endpoints answer', async () => {
stubEndpoints()
render(<App />)
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Now' })).toBeInTheDocument()
})
expect(screen.getByRole('heading', { name: 'Health' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Why' })).toBeInTheDocument()
})
it('shows a loading line before the first status arrives', () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => undefined)))
render(<App />)
expect(screen.getByText('Reading status…')).toBeInTheDocument()
})
it('surfaces a failing endpoint instead of silently showing nothing', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 503, statusText: 'Unavailable' }),
)
render(<App />)
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
})
expect(screen.getByRole('alert')).toHaveTextContent('API returned 503')
})
})

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

@ -0,0 +1,43 @@
import { useCallback } from 'react'
import { HealthCard } from './components/HealthCard'
import { NowCard } from './components/NowCard'
import { WhyTable } from './components/WhyTable'
import { fetchDecisions, fetchHealth, fetchStatus } from './api'
import { usePoll } from './useStatusPoll'
const DECISION_LIMIT = 200
export function App() {
// Stable identities: usePoll re-subscribes whenever the fetcher changes, and
// an inline arrow would restart every interval on each render.
const decisionFetcher = useCallback(() => fetchDecisions(DECISION_LIMIT), [])
const status = usePoll(fetchStatus)
const decisions = usePoll(decisionFetcher)
const health = usePoll(fetchHealth)
const errors = [status.error, decisions.error, health.error].filter(
(e): e is string => e !== null,
)
return (
<main>
<h1>Screen locker</h1>
{errors.length > 0 && (
<div className="error" role="alert">
{/* Shown rather than swallowed: a UI that silently keeps rendering
stale data is the same failure the locker itself had. */}
{errors.map((message) => (
<div key={message}>{message}</div>
))}
</div>
)}
{status.data === null ? (
<p className="muted">Reading status…</p>
) : (
<NowCard status={status.data} />
)}
{health.data !== null && <HealthCard health={health.data} />}
{decisions.data !== null && <WhyTable decisions={decisions.data} />}
</main>
)
}

43
web/src/api.test.ts Normal file
View File

@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchDecisions, fetchHealth, fetchStatus } from './api'
function mockFetch(response: Partial<Response>): void {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('api', () => {
it('returns the parsed body for a 200', async () => {
mockFetch({ ok: true, json: () => Promise.resolve({ total: 1 }) })
await expect(fetchDecisions(5)).resolves.toStrictEqual({ total: 1 })
})
it('passes the limit through in the query string', async () => {
const spy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) })
vi.stubGlobal('fetch', spy)
await fetchDecisions(42)
expect(spy).toHaveBeenCalledWith('/api/decisions?limit=42')
})
it('throws with the status text on a failure, rather than returning empty', async () => {
mockFetch({ ok: false, status: 500, statusText: 'Internal Server Error' })
await expect(fetchStatus()).rejects.toThrow('API returned 500 Internal Server Error')
})
it('requests the health endpoint', async () => {
const spy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) })
vi.stubGlobal('fetch', spy)
await fetchHealth()
expect(spy).toHaveBeenCalledWith('/api/health')
})
it('requests the status endpoint', async () => {
const spy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) })
vi.stubGlobal('fetch', spy)
await fetchStatus()
expect(spy).toHaveBeenCalledWith('/api/status')
})
})

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

@ -0,0 +1,21 @@
import type { DecisionsPayload, HealthPayload, StatusPayload } from './types'
async function getJson<T>(path: string): Promise<T> {
const resp = await fetch(path)
if (!resp.ok) {
throw new Error(`API returned ${String(resp.status)} ${resp.statusText}`)
}
return (await resp.json()) as T
}
export function fetchStatus(): Promise<StatusPayload> {
return getJson<StatusPayload>('/api/status')
}
export function fetchDecisions(limit: number): Promise<DecisionsPayload> {
return getJson<DecisionsPayload>(`/api/decisions?limit=${String(limit)}`)
}
export function fetchHealth(): Promise<HealthPayload> {
return getJson<HealthPayload>('/api/health')
}

View File

@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { HealthCard } from './HealthCard'
import { makeHealth } from '../test/factories'
describe('HealthCard', () => {
it('reports an armed locker', () => {
render(<HealthCard health={makeHealth()} />)
expect(screen.getByText('armed')).toBeInTheDocument()
expect(screen.getByText('enabled and scheduled')).toBeInTheDocument()
})
it('says an unchecked arming state is NOT a pass', () => {
render(
<HealthCard
health={makeHealth({ timers_checked: false, timers: [], armed: false })}
/>,
)
expect(screen.getByText(/This is not a pass/)).toBeInTheDocument()
})
it('shows the disarm marker when enforcement is switched off', () => {
render(<HealthCard health={makeHealth({ disarmed: true, armed: false })} />)
expect(screen.getByText(/present at/)).toBeInTheDocument()
expect(screen.getByText('NOT armed')).toBeInTheDocument()
})
it('shows a disarmed timer with its own reason', () => {
render(
<HealthCard
health={makeHealth({
armed: false,
timers: [
{
name: 'workout-locker.timer',
enabled: false,
scheduled: false,
armed: false,
describe: 'DISARMED workout-locker.timer: not enabled',
},
],
})}
/>,
)
expect(
screen.getByText('DISARMED workout-locker.timer: not enabled'),
).toBeInTheDocument()
})
it('renders unknown ages rather than hiding them', () => {
render(
<HealthCard
health={makeHealth({
log_age_seconds: null,
last_decision_age_seconds: null,
})}
/>,
)
expect(screen.getAllByText('unknown ago')).toHaveLength(2)
})
})

View File

@ -0,0 +1,56 @@
import { formatAge } from '../format'
import type { HealthPayload } from '../types'
interface Props {
readonly health: HealthPayload
}
/**
* Whether the locker could actually fire.
*
* Every row here is something that once went wrong silently: a timer systemd
* deleted to break an ordering cycle, a disarm marker nobody removed, a log
* that stopped being written. Absences are rendered as values so they cannot
* be mistaken for good news.
*/
export function HealthCard({ health }: Props) {
const armedClass = health.armed ? 'state-ok' : 'state-lock'
return (
<section className="card" aria-labelledby="health-heading">
<h2 id="health-heading">Health</h2>
{!health.timers_checked && (
<p className="state-warn">
{'systemctl unavailable — arming was NOT checked. This is not a pass.'}
</p>
)}
<div className="row">
<span>Enforcement</span>
<span className={armedClass}>{health.armed ? 'armed' : 'NOT armed'}</span>
</div>
{health.disarmed && (
<div className="row">
<span>Disarm marker</span>
<span className="state-lock">{`present at ${health.disarm_marker}`}</span>
</div>
)}
{health.timers.map((timer) => (
<div className="row" key={timer.name}>
<span>{timer.name}</span>
<span className={timer.armed ? 'state-ok' : 'state-lock'}>
{timer.armed ? 'enabled and scheduled' : timer.describe}
</span>
</div>
))}
<div className="row">
<span>Workout log written</span>
<span className="muted">{`${formatAge(health.log_age_seconds)} ago`}</span>
</div>
<div className="row">
<span>Last decision recorded</span>
<span className="muted">
{`${formatAge(health.last_decision_age_seconds)} ago`}
</span>
</div>
</section>
)
}

View File

@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { NowCard } from './NowCard'
import { makeStatus } from '../test/factories'
describe('NowCard', () => {
it('shows the cut budget and says why when no workout was logged', () => {
render(<NowCard status={makeStatus()} />)
expect(screen.getByText('No lock')).toBeInTheDocument()
expect(screen.getByText('6h')).toBeInTheDocument()
expect(
screen.getByText('cut from 8h — no workout logged today'),
).toBeInTheDocument()
})
it('shows the earned budget once a workout is logged', () => {
const status = makeStatus({
gaming: {
workout_today: true,
credits_today: 1,
reason: '1 counted workout(s) logged today',
},
})
render(<NowCard status={status} />)
expect(screen.getByText('8h')).toBeInTheDocument()
expect(
screen.getByText('earned by a workout logged today'),
).toBeInTheDocument()
})
it('says the lock would fire when it would', () => {
const base = makeStatus()
render(
<NowCard
status={{
...base,
compliance_state: 'lock',
snapshot: {
...base.snapshot,
lock_explanation: {
...base.snapshot.lock_explanation,
fired: true,
stage: 'full_lock_pending_heat_check',
reason: 'No skip condition applies.',
},
},
}}
/>,
)
expect(screen.getByText('Lock would fire')).toBeInTheDocument()
})
it('counts down the remaining workouts', () => {
render(<NowCard status={makeStatus()} />)
expect(screen.getByText(/3\/5 workouts — 2 to go/)).toBeInTheDocument()
})
it('drops the countdown once the week is met', () => {
const base = makeStatus()
render(
<NowCard
status={{
...base,
snapshot: {
...base.snapshot,
week: { ...base.snapshot.week, counted_count: 5, remaining: 0 },
},
}}
/>,
)
expect(screen.getByText(/5\/5 workouts$/)).toBeInTheDocument()
})
it('flags degraded sources rather than rendering a confident answer', () => {
const base = makeStatus()
render(
<NowCard
status={{
...base,
snapshot: {
...base.snapshot,
lock_explanation: {
...base.snapshot.lock_explanation,
sources_degraded: true,
},
},
}}
/>,
)
expect(screen.getByText(/degraded/)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,58 @@
import { budgetHours } from '../format'
import type { StatusPayload } from '../types'
interface Props {
readonly status: StatusPayload
}
/** The decision as it stands right now, in words rather than slugs. */
export function NowCard({ status }: Props) {
const { snapshot, gaming, compliance_state: state } = status
const { week, lock_explanation: lock } = snapshot
const hours = budgetHours(gaming.workout_today)
return (
<section className="card" aria-labelledby="now-heading">
<h2 id="now-heading">Now</h2>
<p className={`headline state-${state}`}>
{lock.fired ? 'Lock would fire' : 'No lock'}
</p>
<p className="muted">{lock.reason}</p>
<div className="row">
<span>This week</span>
<span>
{week.counted_count}/{week.minimum} workouts
{week.remaining > 0 ? ` — ${String(week.remaining)} to go` : ''}
</span>
</div>
<div className="row">
<span>Today</span>
<span>{gaming.reason}</span>
</div>
<div className="row">
<span>Gaming budget today</span>
<span>
<span className={gaming.workout_today ? 'pill pill-ok' : 'pill pill-lock'}>
{hours}h
</span>{' '}
<span className="muted">
{gaming.workout_today
? 'earned by a workout logged today'
: 'cut from 8h — no workout logged today'}
</span>
</span>
</div>
<div className="row">
<span>Decision stage</span>
<span className="muted">{lock.stage}</span>
</div>
{lock.sources_degraded && (
<div className="row">
<span>Data sources</span>
<span className="state-warn">degraded — some inputs unreadable</span>
</div>
)}
</section>
)
}

View File

@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { WhyTable } from './WhyTable'
import { makeDecisions } from '../test/factories'
describe('WhyTable', () => {
it('renders the decision that hid the 2026-08-29 bug', () => {
render(<WhyTable decisions={makeDecisions()} />)
expect(
screen.getByText('Skipped — weekly minimum already met'),
).toBeInTheDocument()
expect(screen.getByText('5/5')).toBeInTheDocument()
expect(screen.getByText('also: early_bird_window_active')).toBeInTheDocument()
})
it('says how much of the trail is shown', () => {
render(<WhyTable decisions={makeDecisions()} />)
expect(screen.getByText(/Showing 1 of 3000/)).toBeInTheDocument()
})
it('marks a decision that actually locked', () => {
render(
<WhyTable
decisions={makeDecisions({
decisions: [
{
timestamp: '2026-08-30T20:00:00+00:00',
locked: true,
reason: 'enforced',
},
],
})}
/>,
)
expect(screen.getByText('locked')).toBeInTheDocument()
})
it('renders a no-decision run as a dash with no week column', () => {
render(
<WhyTable
decisions={makeDecisions({
decisions: [
{
timestamp: '2026-08-29T20:00:05+00:00',
locked: null,
reason: 'mode_makes_no_decision',
mode: '--sync-only',
},
],
})}
/>,
)
expect(screen.getByText('—')).toBeInTheDocument()
expect(
screen.getByText('No decision (status or sync-only run)'),
).toBeInTheDocument()
})
it('omits the also line when there is none', () => {
render(
<WhyTable
decisions={makeDecisions({
decisions: [
{
timestamp: '2026-08-30T20:00:00+00:00',
locked: false,
reason: 'relaxed_day',
also: '',
weekly_count: 2,
},
],
})}
/>,
)
expect(screen.queryByText(/^also:/)).not.toBeInTheDocument()
// weekly_required missing falls back to 0 rather than rendering undefined.
expect(screen.getByText('2/0')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,62 @@
import { describeReason, formatStamp } from '../format'
import type { DecisionsPayload } from '../types'
interface Props {
readonly decisions: DecisionsPayload
}
/**
* The decision history.
*
* This is the view that would have surfaced the 2026-08-29 bug without anyone
* reading the journal: every half-hourly tick recorded
* `weekly_minimum_met 5/5` while the real total was three.
*/
export function WhyTable({ decisions }: Props) {
return (
<section className="card" aria-labelledby="why-heading">
<h2 id="why-heading">Why</h2>
<p className="muted">
Showing {decisions.returned} of {decisions.total} recorded decisions,
newest first.
</p>
<table>
<thead>
<tr>
<th scope="col">When</th>
<th scope="col">Outcome</th>
<th scope="col">Reason</th>
<th scope="col">Week</th>
</tr>
</thead>
<tbody>
{decisions.decisions.map((decision, index) => (
<tr key={`${decision.timestamp}-${String(index)}`}>
<td>{formatStamp(decision.timestamp)}</td>
<td className={decision.locked === true ? 'state-lock' : 'muted'}>
{decision.locked === null
? '—'
: decision.locked
? 'locked'
: 'no lock'}
</td>
<td>
{describeReason(decision.reason)}
{decision.also !== undefined && decision.also !== '' && (
<div className="muted">also: {decision.also}</div>
)}
</td>
<td className="muted">
{decision.weekly_count === undefined
? ''
: `${String(decision.weekly_count)}/${String(
decision.weekly_required ?? 0,
)}`}
</td>
</tr>
))}
</tbody>
</table>
</section>
)
}

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

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { budgetHours, describeReason, formatAge, formatStamp } from './format'
describe('formatAge', () => {
it('reports a missing age as unknown, never as fresh', () => {
expect(formatAge(null)).toBe('unknown')
})
it('handles a clock skew rather than printing a negative age', () => {
expect(formatAge(-5)).toBe('in the future')
})
it.each([
[30, '30s'],
[90, '1m'],
[3600, '1h'],
[90000, '1d'],
])('renders %i seconds as %s', (seconds, expected) => {
expect(formatAge(seconds)).toBe(expected)
})
})
describe('formatStamp', () => {
it('renders a valid ISO timestamp', () => {
expect(formatStamp('2026-08-29T20:01:02+00:00')).toMatch(/Aug/)
})
it('passes an unparseable value through unchanged', () => {
expect(formatStamp('not a date')).toBe('not a date')
})
})
describe('describeReason', () => {
it('spells out a known slug', () => {
expect(describeReason('weekly_minimum_met')).toBe(
'Skipped — weekly minimum already met',
)
})
it('degrades gracefully for a slug added on the Python side later', () => {
expect(describeReason('some_new_reason')).toBe('some new reason')
})
})
describe('budgetHours', () => {
it('gives 8h when a workout was logged today', () => {
expect(budgetHours(true)).toBe(8)
})
it('cuts to 6h when none was', () => {
expect(budgetHours(false)).toBe(6)
})
})

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

@ -0,0 +1,64 @@
/** Pure formatting helpers, shared by the three views. */
const MINUTE = 60
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
/**
* Render a duration in seconds as a short human age ("3m", "5h", "2d").
*
* Returns "unknown" for null so a missing timestamp can never be mistaken for
* a fresh one — the whole point of the Health view is that silence is visible.
*/
export function formatAge(seconds: number | null): string {
if (seconds === null) return 'unknown'
if (seconds < 0) return 'in the future'
if (seconds < MINUTE) return `${String(Math.floor(seconds))}s`
if (seconds < HOUR) return `${String(Math.floor(seconds / MINUTE))}m`
if (seconds < DAY) return `${String(Math.floor(seconds / HOUR))}h`
return `${String(Math.floor(seconds / DAY))}d`
}
/** Render an ISO-8601 timestamp as a local "MMM DD HH:MM" stamp. */
export function formatStamp(iso: string): string {
const when = new Date(iso)
if (Number.isNaN(when.getTime())) return iso
return when.toLocaleString(undefined, {
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
/**
* Turn a decision reason slug into a sentence.
*
* Falls back to the slug with underscores stripped, so a reason added to the
* Python side later still reads sensibly here instead of rendering blank.
*/
export function describeReason(reason: string): string {
const known: Record<string, string> = {
enforced: 'Locked — no skip condition applied',
weekly_minimum_met: 'Skipped — weekly minimum already met',
scheduled_skip_day: 'Skipped — this date was scheduled off',
relaxed_day: 'Relaxed day (Tue–Thu) — lock optional',
relaxed_day_already_skipped: 'Skipped — relaxed day already dismissed',
workout_logged_today: 'Skipped — a workout was logged today',
early_bird_window_active: 'Skipped — inside the early-bird window',
early_bird_banked: 'Skipped — early-bird marker banked',
early_bird_auto_upgraded: 'Skipped — early-bird workout confirmed',
sick_day: 'Skipped — sick day',
sick_day_auto_upgraded: 'Skipped — sick day, workout confirmed',
wake_alarm_skip: 'Skipped — earned by the wake alarm',
heat_skip: 'Skipped — too hot',
no_sick_day_to_verify: 'Nothing to verify',
mode_makes_no_decision: 'No decision (status or sync-only run)',
}
return known[reason] ?? reason.replace(/_/g, ' ')
}
/** Budget hours the enforcer will apply, given whether today has a workout. */
export function budgetHours(workoutToday: boolean): number {
return workoutToday ? 8 : 6
}

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

@ -0,0 +1,133 @@
/*
* Tokens from ~/utils/unified-design-system (DOCS-tokens.md). Values are copied
* rather than imported because that system ships as documentation plus
* per-stack generators, not an npm package -- keep these in step with it rather
* than inventing new ones.
*/
:root {
--ink: #211d1b;
--ink-raised-1: #2b2624;
--ink-raised-2: #38312e;
--line-dark: #463e3a;
--text-on-dark: #eceae9;
--muted-on-dark: #aaa09a;
--accent: #b8862e;
--success: #8a9a3c;
--warning: #e0a63c;
--danger: #e2585f;
--space-1: 4px;
--space-2: 8px;
--space-3: 16px;
--space-4: 24px;
--space-5: 32px;
--radius: 8px;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--ink);
color: var(--text-on-dark);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.5;
}
main {
margin: 0 auto;
max-width: 900px;
padding: var(--space-4) var(--space-3);
}
h1 {
font-size: 24px;
letter-spacing: -0.1px;
margin: 0 0 var(--space-4);
}
h2 {
font-size: 20px;
margin: 0 0 var(--space-3);
}
.card {
background: var(--ink-raised-1);
border: 1px solid var(--line-dark);
border-radius: var(--radius);
margin-bottom: var(--space-4);
padding: var(--space-4);
}
.headline {
font-size: 32px;
letter-spacing: -0.2px;
margin: 0 0 var(--space-2);
}
.muted {
color: var(--muted-on-dark);
}
.state-ok { color: var(--success); }
.state-warn { color: var(--warning); }
.state-lock { color: var(--danger); }
.row {
border-top: 1px solid var(--line-dark);
display: flex;
gap: var(--space-3);
justify-content: space-between;
padding: var(--space-2) 0;
}
.row:first-of-type {
border-top: none;
}
table {
border-collapse: collapse;
font-size: 14px;
width: 100%;
}
th {
color: var(--muted-on-dark);
font-weight: 600;
letter-spacing: 0.05em;
text-align: left;
}
th,
td {
border-bottom: 1px solid var(--line-dark);
padding: var(--space-2) var(--space-2) var(--space-2) 0;
vertical-align: top;
}
.pill {
background: var(--ink-raised-2);
border-radius: 999px;
font-size: 12px;
letter-spacing: 0.1em;
padding: var(--space-1) var(--space-2);
white-space: nowrap;
}
.pill-lock { background: var(--danger); color: var(--ink); }
.pill-ok { background: var(--success); color: var(--ink); }
.error {
border: 1px solid var(--danger);
border-radius: var(--radius);
color: var(--danger);
margin-bottom: var(--space-4);
padding: var(--space-3);
}

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

@ -0,0 +1,12 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './index.css'
const root = document.getElementById('root')
if (root === null) throw new Error('#root is missing from index.html')
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)

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

@ -0,0 +1,88 @@
import type {
DecisionsPayload,
HealthPayload,
StatusPayload,
} from '../types'
export function makeStatus(over: Partial<StatusPayload> = {}): StatusPayload {
return {
snapshot: {
today: {
date: '2026-08-29',
label: 'Sat Aug 29',
entry_types: [],
source: '',
counted: false,
day_count: 0,
is_sick_day: false,
},
week: {
days: [],
counted_count: 3,
minimum: 5,
remaining: 2,
extra: 0,
},
lock_explanation: {
fired: false,
stage: 'scheduled_skip',
reason: 'Manually scheduled skip day.',
trace: [],
heat_skip_evaluated: false,
sources_degraded: false,
},
generated_at: '2026-08-29T20:30:00+00:00',
},
summary_line: '… 3/5 workouts',
compliance_state: 'warn',
gaming: {
workout_today: false,
credits_today: 0,
reason: 'no counted workout logged today',
},
...over,
}
}
export function makeHealth(over: Partial<HealthPayload> = {}): HealthPayload {
return {
timers_checked: true,
timers: [
{
name: 'workout-locker.timer',
enabled: true,
scheduled: true,
armed: true,
describe: 'OK workout-locker.timer: enabled and scheduled',
},
],
armed: true,
disarmed: false,
disarm_marker: '/home/kuhy/.local/share/screen_locker/DISARMED',
log_file: '/home/kuhy/screen-locker/screen_locker/log.json',
log_age_seconds: 3600,
decision_log_file: '/home/kuhy/.local/share/screen_locker/decisions.jsonl',
last_decision_age_seconds: 120,
...over,
}
}
export function makeDecisions(
over: Partial<DecisionsPayload> = {},
): DecisionsPayload {
return {
total: 3000,
returned: 1,
decisions: [
{
timestamp: '2026-08-29T20:01:02+00:00',
locked: false,
reason: 'weekly_minimum_met',
weekly_count: 5,
weekly_required: 5,
also: 'early_bird_window_active',
},
],
...over,
}
}

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

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

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

@ -0,0 +1,102 @@
/** Shapes served by screen_locker._web_payload. */
/** One predicate the lock decision walked past, and whether it fired. */
export interface TraceStep {
readonly name: string
readonly fired: boolean
readonly reason: string
}
/** Why the locker would or would not fire right now. */
export interface LockExplanation {
readonly fired: boolean
readonly stage: string
readonly reason: string
readonly trace: readonly TraceStep[]
readonly heat_skip_evaluated: boolean
readonly sources_degraded: boolean
}
/** One day of this ISO week. */
export interface DayStatus {
readonly date: string
readonly label: string
readonly entry_types: readonly string[]
readonly source: string
readonly counted: boolean
readonly day_count: number
readonly is_sick_day: boolean
}
/** This ISO week against the weekly minimum. */
export interface WeeklySummary {
readonly days: readonly DayStatus[]
readonly counted_count: number
readonly minimum: number
readonly remaining: number
readonly extra: number
}
/** The parts of the status snapshot this UI renders. */
export interface StatusSnapshot {
readonly today: DayStatus
readonly week: WeeklySummary
readonly lock_explanation: LockExplanation
readonly generated_at: string
}
/** The fact the gaming budget turns on. */
export interface GamingFact {
readonly workout_today: boolean
readonly credits_today: number
readonly reason: string
}
/** GET /api/status. */
export interface StatusPayload {
readonly snapshot: StatusSnapshot
readonly summary_line: string
readonly compliance_state: string
readonly gaming: GamingFact
}
/** One recorded decision from decisions.jsonl. */
export interface Decision {
readonly timestamp: string
readonly locked: boolean | null
readonly reason: string
readonly detail?: string
readonly weekly_count?: number
readonly weekly_required?: number
readonly also?: string
readonly mode?: string
}
/** GET /api/decisions. */
export interface DecisionsPayload {
readonly total: number
readonly returned: number
readonly decisions: readonly Decision[]
}
/** One systemd timer's arming state. */
export interface TimerHealth {
readonly name: string
readonly enabled: boolean
readonly scheduled: boolean
readonly armed: boolean
readonly describe: string
}
/** GET /api/health. */
export interface HealthPayload {
readonly timers_checked: boolean
readonly timers: readonly TimerHealth[]
readonly armed: boolean
readonly disarmed: boolean
readonly disarm_marker: string
readonly log_file: string
readonly log_age_seconds: number | null
readonly decision_log_file: string
readonly last_decision_age_seconds: number | null
}

View File

@ -0,0 +1,92 @@
import { renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { POLL_MS, usePoll } from './useStatusPoll'
afterEach(() => {
vi.useRealTimers()
})
describe('usePoll', () => {
it('loads immediately rather than waiting for the first interval', async () => {
const fetcher = vi.fn().mockResolvedValue('value')
const { result } = renderHook(() => usePoll(fetcher))
await waitFor(() => {
expect(result.current.data).toBe('value')
})
expect(result.current.error).toBeNull()
})
it('surfaces an Error message', async () => {
const fetcher = vi.fn().mockRejectedValue(new Error('boom'))
const { result } = renderHook(() => usePoll(fetcher))
await waitFor(() => {
expect(result.current.error).toBe('boom')
})
})
it('stringifies a non-Error rejection instead of rendering nothing', async () => {
const fetcher = vi.fn().mockRejectedValue('plain string')
const { result } = renderHook(() => usePoll(fetcher))
await waitFor(() => {
expect(result.current.error).toBe('plain string')
})
})
it('clears a previous error once a load succeeds again', async () => {
const fetcher = vi
.fn()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValue('ok')
const { result, rerender } = renderHook(
({ interval }: { interval: number }) => usePoll(fetcher, interval),
{ initialProps: { interval: 100000 } },
)
await waitFor(() => {
expect(result.current.error).toBe('boom')
})
rerender({ interval: 200000 })
await waitFor(() => {
expect(result.current.data).toBe('ok')
})
expect(result.current.error).toBeNull()
})
it('polls again on the interval', async () => {
vi.useFakeTimers()
const fetcher = vi.fn().mockResolvedValue('value')
renderHook(() => usePoll(fetcher, 1000))
expect(fetcher).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(2000)
expect(fetcher).toHaveBeenCalledTimes(3)
})
it('stops updating after unmount', async () => {
const fetcher = vi.fn().mockResolvedValue('value')
const { result, unmount } = renderHook(() => usePoll(fetcher))
unmount()
await Promise.resolve()
expect(result.current.data).toBeNull()
})
it('ignores a rejection that lands after unmount', async () => {
// The cancelled guard in the catch path: without it React warns about a
// state update on an unmounted component, and a stale error would stick.
let reject: (reason: Error) => void = () => undefined
const fetcher = vi.fn(
() =>
new Promise<string>((_resolve, rej) => {
reject = rej
}),
)
const { result, unmount } = renderHook(() => usePoll(fetcher))
unmount()
reject(new Error('late failure'))
await Promise.resolve()
expect(result.current.error).toBeNull()
})
it('exposes a sane default interval', () => {
expect(POLL_MS).toBeGreaterThan(0)
})
})

49
web/src/useStatusPoll.ts Normal file
View File

@ -0,0 +1,49 @@
import { useEffect, useState } from 'react'
export const POLL_MS = 15000
interface PollResult<T> {
readonly data: T | null
readonly error: string | null
}
/**
* Poll a fetcher on an interval, keeping the last good value on failure.
*
* Deliberately has no `document.hidden` guard. The enforcer's equivalent hook
* had one and it swallowed the first load, leaving automation-driven tabs stuck
* on the loading message forever; see steam-backlog-enforcer useBudgetPoll.
*/
export function usePoll<T>(
fetcher: () => Promise<T>,
intervalMs: number = POLL_MS,
): PollResult<T> {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const load = () => {
fetcher()
.then((value) => {
if (!cancelled) {
setData(value)
setError(null)
}
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : String(e))
}
})
}
load()
const handle = setInterval(load, intervalMs)
return () => {
cancelled = true
clearInterval(handle)
}
}, [fetcher, intervalMs])
return { data, error }
}

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

@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["es2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"moduleResolution": "bundler",
"types": ["vite/client"],
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View File

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

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

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

8
web/tsconfig.test.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo",
"types": ["vite/client", "node"]
},
"include": ["src", "vite.config.ts"]
}

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

@ -0,0 +1,35 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
// The Python API (`screen-locker-web`) runs on 127.0.0.1:8770 — 8000 belongs to
// steam-backlog-enforcer's own UI. 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:8770',
},
},
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,
},
},
},
})