Shutdown: walks as workout, rest days, grace floor, workout ledger (earned-time 0.4.0)

- A day's RunnerUp walks (_Walking.tcx) count once their moving time sums to 40 min
- Rest days: declared ahead, 2 per ISO week, HMAC-signed; pay the first workout unit
- Grace floor: shutdown >= first task + 60 min (capped); skipped on a sick day
- Workout ledger: one signed credit row per log credit slot and rest day
- Day-aware ladder amounts from earned_time 0.4.0; preview_shutdown dry-run script

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrjUTo1yhgAsUUasdw4bpG

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-09 10:43:45 +02:00
parent 6334bd54bf
commit 373a075606
36 changed files with 2940 additions and 61 deletions

4
.gitignore vendored
View File

@ -41,3 +41,7 @@ web/dist/
web/coverage/
web/node_modules
web/dist
screen_locker/rest_days.json
screen_locker/grace_floor.json
screen_locker/grace_floor.lock
screen_locker/rest_days.lock

View File

@ -10,7 +10,7 @@ dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays",
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.3.0#subdirectory=earned_time",
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time",
]
[project.scripts]

View File

@ -4,7 +4,7 @@ bandit==1.9.4
codespell==2.4.3
coverage==7.16.2
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.3.0#subdirectory=earned_time
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
mcp==2.3.0

View File

@ -16,8 +16,10 @@ from typing import TYPE_CHECKING
from screen_locker._constants import SHUTDOWN_BASE_FILE
from screen_locker._decision_log import record_no_decision
from screen_locker._manual_cli import run_manual_log
from screen_locker._rest_day_cli import run_declare_rest_day
from screen_locker._shutdown_base import apply_flat_bonuses_if_new
from screen_locker._status import run_status
from screen_locker._workout_ledger_cli import run_backfill
if TYPE_CHECKING:
from screen_locker.screen_lock import ScreenLocker
@ -28,6 +30,8 @@ _logger = logging.getLogger(__name__)
_LOG_FILE_NAME = "log.json"
_LOG_MANUAL_FLAG = "--log-manual-workout"
_REST_DAY_FLAG = "--declare-rest-day"
_BACKFILL_FLAG = "--backfill-workout-ledger"
def _headless_locker(locker_cls: type[ScreenLocker]) -> ScreenLocker:
@ -65,6 +69,14 @@ def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None:
rest = argv[argv.index(_LOG_MANUAL_FLAG) + 1 :]
sys.exit(run_manual_log(_headless_locker(locker_cls).log_file, rest))
if _REST_DAY_FLAG in argv:
# Headless, no Tk: sign a FUTURE rest day (see screen_locker._rest_day).
sys.exit(run_declare_rest_day(argv[argv.index(_REST_DAY_FLAG) + 1 :]))
if _BACKFILL_FLAG in argv:
rest = argv[argv.index(_BACKFILL_FLAG) + 1 :]
sys.exit(run_backfill(_headless_locker(locker_cls).log_file, rest))
if "--sync-only" in argv:
# Headless sync for the timer unit: pull other devices' workouts and
# apply their credit, with no Tk and no lock screen. Sync used to run

View File

@ -12,8 +12,16 @@ from gatelock.log_integrity import DEFAULT_HMAC_KEY_FILE
# log.json/sick_history.json below.
INSTANCE_LOCK_FILE = default_runtime_dir() / "screen_locker-instance.lock"
# Single-sourced from gatelock so the literal key path can't drift.
# Single-sourced from gatelock so the literal key path can't drift. The key is
# root:root 0644 ON PURPOSE, never 0600: leetcode-guard, book-guard and
# anki-guard run as user timers and sign their ledgers with it, and this
# locker signs log.json, rest_days.json and the workout ledger as the user.
# Nothing in this repo may chmod it; root ownership keeps it from being
# replaced, which is what the signatures rely on.
HMAC_KEY_FILE = DEFAULT_HMAC_KEY_FILE
# The workout ledger (earned_time.WORKOUT.ledger): one signed credit row per
# verified RunnerUp TCX credit and per rest day, resolved at call time.
WORKOUT_LEDGER_RELATIVE = Path(".local/share/workout_locker/ledger.json")
SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage)
PHONE_PENALTY_DELAY_DEMO = 10
PHONE_PENALTY_DELAY_PRODUCTION = 100
@ -120,6 +128,17 @@ SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json"
EXTRA_BENEFITS_FILE = Path(__file__).resolve().parent / "extra_benefits_state.json"
# State file storing the base (pre-bonus) shutdown hours and last reset date.
SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json"
# HMAC-signed rest days, declared ahead (see _rest_day), and how far the grace
# floor has lifted today's shutdown above the earned time (see _grace_floor).
REST_DAY_FILE = Path(__file__).resolve().parent / "rest_days.json"
REST_DAYS_PER_ISO_WEEK = 2
GRACE_STATE_FILE = Path(__file__).resolve().parent / "grace_floor.json"
GRACE_MINUTES = 60
# A day's RunnerUp walks (commute legs) count as the workout once their
# MOVING time sums to this; moving = trackpoint pairs at walking speed.
WALK_MIN_MOVING_MINUTES = 40
WALK_SPEED_MPS = (0.5, 3.0)
WALK_MAX_GAP_SECONDS = 30
# Workout records this PC must keep deleted in the sync store, pushed as
# tombstones on every tick (see _sync_tombstones for why once is not enough).
SYNC_TOMBSTONES_FILE = Path(__file__).resolve().parent / "sync_tombstones.json"

View File

@ -16,6 +16,7 @@ midnight, so a 30-minute earner is applied exactly.
from __future__ import annotations
import functools
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Final
@ -25,7 +26,7 @@ import earned_time
from screen_locker._constants import HMAC_KEY_FILE
if TYPE_CHECKING:
from datetime import datetime
from datetime import date, datetime
_logger: Final = logging.getLogger(__name__)
@ -86,3 +87,48 @@ def flat_answers() -> dict[str, bool | None]:
)
answers[earner.name] = answer
return answers
# The day-aware ladder API (earned_time >= 0.4, from 2026-10-10). Looked up at
# call time so the installed 0.3.0 -- which has none of it -- still runs, on
# the raw fields it always used.
def first_minutes(earner: earned_time.Earner, day: date) -> int:
"""What ``earner``'s first unit pushes shutdown later by on ``day``."""
fn = getattr(earned_time, "shutdown_minutes_for", None)
return earner.shutdown_minutes if fn is None else int(fn(earner, day))
def extra_minutes(earner: earned_time.Earner, day: date) -> int:
"""What each further unit of a counted earner earns on ``day``."""
fn = getattr(earned_time, "extra_shutdown_minutes_for", None)
return earner.extra_shutdown_minutes if fn is None else int(fn(earner, day))
def ceiling(day: date) -> int:
"""The latest shutdown ``day`` can earn, minutes after its midnight."""
fn = getattr(earned_time, "shutdown_ceiling_for", None)
return earned_time.SHUTDOWN_CEILING_MINUTES if fn is None else int(fn(day))
def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
"""Unix time of ``earner``'s first verified credit counting for ``day``.
``None`` when there is none, when it cannot be checked, and on an
``earned_time`` without ``first_credit_at`` (0.3.0) -- logged, because
then the grace floor sees no ledger earner at all.
"""
fn = getattr(earned_time, "first_credit_at", None)
if fn is None:
_warn_no_first_credit()
return None
stamp: float | None = fn(earner, ledger_file(earner), HMAC_KEY_FILE, day)
return stamp
@functools.cache
def _warn_no_first_credit() -> None:
"""Once per process: the installed earned_time predates first_credit_at."""
_logger.warning(
"earned_time has no first_credit_at (needs >= 0.4); the grace floor "
"sees no ledger earner, only the workout log"
)

View File

@ -0,0 +1,204 @@
"""Grace floor: shutdown is never earlier than the day's first task + 60 min.
Someone who gets home at 18:10 and finishes their first task at 18:55 is not
cut off at 19:00. The floor is ``first done + GRACE_MINUTES``, capped at the
day's ceiling. "First done" is the earliest of every ledger earner's first
verified credit (``earned_time.first_credit_at``) and the workout's own
completion time -- the end of the run or walk, never the moment a sync
happened to log it, so a late upload does not move the floor later.
**A floor, not a bonus.** The config is otherwise read-add-write: every
earner adds its minutes to whatever is there. Lifting it to the floor and
then letting the next earner add on top would pay the lift twice (anki at
21:00 lifts to 22:00; reading then makes 22:30 where max(earned, floor) is
22:00). So the lift is remembered for the day in ``grace_floor.json`` and
later additions are paid out of it first: with ``E`` earned, floor ``G`` and
lift ``L = max(0, G - E)``, adding ``m`` moves the config by
``max(0, m - L)`` and leaves ``L' = max(0, L - m)`` -- which keeps the config
at ``max(E + m, G)`` without ever needing to know ``E``.
"""
from __future__ import annotations
from datetime import date, datetime
import json
import logging
from typing import TYPE_CHECKING
import earned_time
from screen_locker._bonus_lock import bonus_lock
from screen_locker._constants import GRACE_MINUTES, GRACE_STATE_FILE
from screen_locker._day import today_str
from screen_locker._earned import ceiling, first_credit_time, flat_earners, hhmm
from screen_locker._log_io import load_workout_log
from screen_locker._rest_day import is_rest_day
from screen_locker._sick_tracker import is_sick_day, load_history
from screen_locker._weekly_check import credit_key, day_workout_index
from screen_locker._workout_ledger import entry_done_at
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
_MON_WED = frozenset({0, 1, 2})
def workout_done_at(log_file: Path, day: date) -> float | None:
"""The completion time of ``day``'s first counted workout, if any."""
iso = day.isoformat()
entries = [
e for e in load_workout_log(log_file).get(iso, []) if isinstance(e, dict)
]
siblings = day_workout_index(entries)
times = [
stamp
for index, entry in enumerate(entries)
if credit_key(iso, index, entry, siblings) is not None
and (stamp := entry_done_at(entry, day)) is not None
]
return min(times, default=None)
def _workout_time(day: date, log_file: Path | None, *, rest_day: bool) -> float | None:
"""When ``day``'s first workout was done: the ledger's and the log's view.
The workout ledger (``earned_time`` >= 0.4) holds only RunnerUp and rest
days, so the log still answers for manual and StrongLifts workouts. On a
rest day the ledger is skipped: its rest row is stamped at the day's
midnight, which is not a completion and would otherwise erase the floor.
"""
times: list[float | None] = []
if log_file is not None:
times.append(workout_done_at(log_file, day))
if not rest_day and earned_time.WORKOUT.ledger is not None:
times.append(first_credit_time(earned_time.WORKOUT, day))
return min((t for t in times if t is not None), default=None)
def first_done_at(
day: date, log_file: Path | None, *, rest_day: bool = False
) -> float | None:
"""The earliest moment any earner was done on ``day``; ``None`` if none."""
times = [first_credit_time(e, day) for e in flat_earners()]
times.append(_workout_time(day, log_file, rest_day=rest_day))
on_day = [
t
for t in times
if t is not None and datetime.fromtimestamp(t).astimezone().date() == day
]
return min(on_day, default=None)
def sick_on(day: date) -> bool:
"""Whether ``day`` is a sick day, from the sick-day history itself.
On a sick day the sick-day shutdown wins and there is no grace floor (the
user's call, 2026-10-09): the floor would otherwise lift the sick-day
reduction straight back up.
"""
return is_sick_day(load_history(), today=day.isoformat())
def grace_for(first_done: float | None, day: date) -> int | None:
"""The floor for ``day`` in minutes after midnight, or ``None`` if none."""
if first_done is None:
return None
moment = datetime.fromtimestamp(first_done).astimezone()
if moment.date() != day:
return None
done = moment.hour * 60 + moment.minute
return min(ceiling(day), done + GRACE_MINUTES)
def _load_lift(state_file: Path, today: str) -> int:
if not state_file.exists():
return 0 # no lift recorded yet: nothing to absorb
try:
raw = json.loads(state_file.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
_logger.warning(
"Grace state %s unreadable (%s); lift taken as 0", state_file, exc
)
return 0
if not isinstance(raw, dict) or raw.get("date") != today:
return 0
return max(0, int(raw.get("lift", 0)))
def save_lift(lift: int, state_file: Path | None = None) -> None:
"""Record how far the floor lifts today's shutdown above the earned time."""
state_file = state_file or GRACE_STATE_FILE
payload = {"date": today_str(), "lift": max(0, lift)}
try:
state_file.write_text(json.dumps(payload), encoding="utf-8")
except OSError as exc:
_logger.warning("Could not record the grace lift in %s (%s)", state_file, exc)
def plan_absorb(minutes: int, state_file: Path | None = None) -> tuple[int, int]:
"""How much of an ``minutes`` addition moves the config, and the lift left.
Commit the lift with :func:`save_lift` only once the write succeeded.
"""
lift = _load_lift(state_file or GRACE_STATE_FILE, today_str())
return max(0, minutes - lift), max(0, lift - minutes)
def _tonight(config: tuple[int, int, int], day: date) -> int:
mon_wed, thu_sun, _ = config
return mon_wed if day.weekday() in _MON_WED else thu_sun
class GraceFloorMixin:
"""Lifts tonight's shutdown to the grace floor; composed into ShutdownMixin."""
if TYPE_CHECKING:
def _read_shutdown_config(self) -> tuple[int, int, int] | None: ...
def _write_shutdown_config(
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ...
) -> bool: ...
def _apply_grace_floor(
self, log_file: Path | None, *, first_done: float | None = None
) -> bool:
"""Lift tonight's shutdown to the grace floor if it sits below it.
``first_done`` overrides the ledgers' answer (the preview script's
simulations). Returns True when the config was raised on this call.
"""
day = date.fromisoformat(today_str())
if sick_on(day):
_logger.info("Grace floor: skipped, today is a sick day")
return False
if first_done is None:
first_done = first_done_at(day, log_file, rest_day=is_rest_day(day))
floor = grace_for(first_done, day)
if floor is None:
return False
with bonus_lock(GRACE_STATE_FILE):
config = self._read_shutdown_config()
if config is None:
_logger.warning("Grace floor: shutdown config unreadable; not applied")
return False
current = _tonight(config, day)
if current >= floor:
return False
mon_wed, thu_sun, morning = config
if not self._write_shutdown_config(
max(mon_wed, floor), max(thu_sun, floor), morning, restore=True
):
_logger.warning("Grace floor: failed to write shutdown config")
return False
lift = _load_lift(GRACE_STATE_FILE, today_str()) + floor - current
save_lift(lift, GRACE_STATE_FILE)
_logger.info(
"Grace floor: shutdown %s -> %s (first task done + %d min).",
hhmm(current),
hhmm(floor),
GRACE_MINUTES,
)
return True

View File

@ -16,6 +16,7 @@ from screen_locker._bonus_lock import bonus_lock
from screen_locker._day import today_str
from screen_locker._log_io import load_workout_log
from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE, manual_sync_record_id
from screen_locker._workout_ledger import record_log_entry
if TYPE_CHECKING:
from collections.abc import Mapping
@ -162,6 +163,10 @@ def _write_signed_entry_locked(
json.dump(logs, f, indent=2)
except OSError as e:
_logger.warning("Could not save workout log: %s", e)
else:
# The one chokepoint every credit passes, so the signed workout
# ledger (earned_time's view of the workout) can never miss one.
record_log_entry(date, entries)
return RecordResult(appended=True, prior_entries=prior)

193
screen_locker/_rest_day.py Normal file
View File

@ -0,0 +1,193 @@
"""Rest days: declared AHEAD of time, at most two per ISO week, HMAC-signed.
A declared rest day earns the workout's first-unit shutdown bonus, so the day
resolves as if its workout were done. Two rules keep it from being an escape:
* **Ahead only.** A rest day must be declared on an earlier local day than
the one it names -- never "today", so it cannot be reached for at the
moment the shutdown bites. Checked when declaring AND again when reading
(``declared_on < day``), so a row written by any other route still fails.
The CLI refuses to run on a skewed clock (``check_clock_skew``), the one
way to make tomorrow look like today.
* **Two per ISO week**, counted by the rest day's own week over the verified
rows, oldest declaration first; a third row in a week is ignored on read.
Each declaration is mirrored as a ``rest_day`` credit row in the workout
ledger (:mod:`screen_locker._workout_ledger`), which ``earned_time`` reads.
Rows live in ``rest_days.json`` as ``{"entries": [...]}``, each signed with
``earned_time.entry_signature`` and the shared key, the same as every gate's
ledger: editing the file without the key breaks the row, and a broken row
earns nothing.
Unlike a sick day -- decided on the day, at the lock screen, behind a
countdown, rate-limited over 7/30/90 days, and paid back as workout debt -- a
rest day is planned, costs no debt, and only touches shutdown time. It does
not stand down the screen lock or the weekly minimum (``freedays`` does that).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, date, datetime
import json
import logging
from typing import TYPE_CHECKING, Any
import earned_time
from screen_locker import _earned
from screen_locker._constants import REST_DAY_FILE, REST_DAYS_PER_ISO_WEEK
from screen_locker._workout_ledger import append_rows, rest_row
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
_KIND = "rest_day"
@dataclass(frozen=True)
class Declared:
"""Outcome of :func:`declare`: ``ok`` and a sentence a human can act on."""
ok: bool
reason: str
def _key(key_file: Path) -> bytes | None:
try:
key = key_file.read_bytes().strip()
except OSError as exc:
_logger.warning("Cannot read the rest-day signing key %s (%s)", key_file, exc)
return None
return key or None
def _rows(path: Path) -> list[Any] | None:
"""The file's ``entries``; ``[]`` if it does not exist, ``None`` if unreadable."""
if not path.exists():
return []
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
_logger.warning("Rest-day file %s is unreadable (%s)", path, exc)
return None
rows = raw.get("entries") if isinstance(raw, dict) else None
if not isinstance(rows, list):
_logger.warning("Rest-day file %s has no entries array", path)
return None
return rows
def _iso_week(day: date) -> tuple[int, int]:
year, week, _ = day.isocalendar()
return year, week
def _valid(row: object, key: bytes) -> bool:
"""A signed rest-day row, declared on an earlier day than it names."""
if not isinstance(row, dict) or row.get("kind") != _KIND:
return False
if not earned_time.verified(row, key):
_logger.warning("Rest-day row %r fails its signature; ignored", row.get("day"))
return False
try:
return date.fromisoformat(str(row["declared_on"])) < date.fromisoformat(
str(row["day"])
)
except (KeyError, ValueError) as exc:
_logger.warning("Rest-day row %r is malformed (%s); ignored", row, exc)
return False
def rest_day_declarations(
path: Path | None = None, key_file: Path | None = None
) -> dict[date, float]:
"""Every rest day that counts, with the unix time it was declared.
Counted: verified, declared ahead, within the weekly cap (oldest first).
"""
key = _key(key_file or _earned.HMAC_KEY_FILE)
rows = _rows(path or REST_DAY_FILE)
if key is None or rows is None:
return {}
good = sorted(
(r for r in rows if _valid(r, key)), key=lambda r: float(r["declared_at"])
)
counted: dict[date, float] = {}
for row in good:
day = date.fromisoformat(str(row["day"]))
same_week = sum(1 for d in counted if _iso_week(d) == _iso_week(day))
if day not in counted and same_week < REST_DAYS_PER_ISO_WEEK:
counted[day] = float(row["declared_at"])
return counted
def rest_days(path: Path | None = None, key_file: Path | None = None) -> set[date]:
"""Every rest day that counts (see :func:`rest_day_declarations`)."""
return set(rest_day_declarations(path, key_file))
def is_rest_day(
day: date, path: Path | None = None, key_file: Path | None = None
) -> bool:
"""Whether ``day`` is a declared rest day (``False`` when unverifiable)."""
return day in rest_days(path, key_file)
def declare(
day: date,
*,
now: datetime | None = None,
path: Path | None = None,
key_file: Path | None = None,
ledger: Path | None = None,
) -> Declared:
"""Sign and append a rest day for ``day``, if the rules allow it.
Paths default at call time to ``REST_DAY_FILE`` and the key ``_earned``
verifies every ledger with, so the test suite's redirects apply.
"""
path = path or REST_DAY_FILE
key_file = key_file or _earned.HMAC_KEY_FILE
moment = (now or datetime.now(tz=UTC)).astimezone()
if day <= moment.date():
return Declared(
ok=False,
reason=f"{day} is not in the future: rest days are declared ahead",
)
existing = rest_days(path, key_file)
if day in existing:
return Declared(ok=True, reason=f"{day} is already a rest day")
used = sum(1 for d in existing if _iso_week(d) == _iso_week(day))
if used >= REST_DAYS_PER_ISO_WEEK:
return Declared(
ok=False,
reason=f"ISO week {_iso_week(day)[1]} already has "
f"{REST_DAYS_PER_ISO_WEEK} rest days",
)
key = _key(key_file)
rows = _rows(path)
if key is None or rows is None:
return Declared(ok=False, reason="rest-day file or signing key unreadable")
row: dict[str, object] = {
"kind": _KIND,
"day": day.isoformat(),
"declared_on": moment.date().isoformat(),
"declared_at": moment.timestamp(),
}
row["hmac"] = earned_time.entry_signature(row, key)
rows.append(row)
try:
path.write_text(json.dumps({"entries": rows}, indent=2), encoding="utf-8")
except OSError as exc:
_logger.warning("Could not write rest day to %s (%s)", path, exc)
return Declared(ok=False, reason=f"could not write {path}: {exc}")
append_rows([rest_row(day, moment.timestamp())], ledger, key_file)
left = REST_DAYS_PER_ISO_WEEK - used - 1
return Declared(
ok=True,
reason=f"{day} is a rest day ({left} left in ISO week {_iso_week(day)[1]})",
)

View File

@ -0,0 +1,81 @@
"""``--declare-rest-day``: plan a rest day from the command line.
python -m screen_locker.screen_lock --declare-rest-day 2026-10-12
python -m screen_locker.screen_lock --declare-rest-day tomorrow
python -m screen_locker.screen_lock --declare-rest-day --list
The clock is checked against NTP first and an unconfirmable clock is a
refusal, not a pass: unlike the lock's own check (which fails open when NTP
is unreachable), a wound-back clock with the network off is exactly how
"today" would be passed off as tomorrow.
"""
from __future__ import annotations
import argparse
from datetime import date, timedelta
import logging
import sys
from typing import TYPE_CHECKING
from screen_locker._constants import MAX_CLOCK_SKEW_SECONDS
from screen_locker._day import today_str
from screen_locker._rest_day import declare, rest_days
from screen_locker._time_check import _query_ntp_offset
if TYPE_CHECKING:
from collections.abc import Sequence
_logger = logging.getLogger(__name__)
def _say(message: str) -> None:
sys.stdout.write(f"{message}\n")
def _parse_day(raw: str) -> date:
today = date.fromisoformat(today_str())
if raw == "tomorrow":
return today + timedelta(days=1)
return date.fromisoformat(raw)
def _clock_confirmed() -> str | None:
"""``None`` if NTP confirms the clock, else why the declaration is refused."""
offset = _query_ntp_offset()
if offset is None:
return "NTP is unreachable, so the clock cannot be confirmed; try online"
if abs(offset) > MAX_CLOCK_SKEW_SECONDS:
return f"the clock is off by {abs(offset):.0f}s from NTP"
return None
def run_declare_rest_day(argv: Sequence[str]) -> int:
"""Declare (or list) rest days. Returns a process exit code."""
parser = argparse.ArgumentParser(
prog="screen-locker --declare-rest-day",
description="Declare a FUTURE rest day (max 2 per ISO week).",
)
parser.add_argument("day", nargs="?", help="YYYY-MM-DD or 'tomorrow'")
parser.add_argument("--list", action="store_true", help="list rest days")
args = parser.parse_args(list(argv))
if args.list or args.day is None:
days = sorted(rest_days())
_say("\n".join(d.isoformat() for d in days) or "no rest days declared")
return 0
try:
day = _parse_day(args.day)
except ValueError as exc:
_logger.warning("Unparsable rest day %r: %s", args.day, exc)
_say(f"error: {exc}")
return 2
refusal = _clock_confirmed()
if refusal is not None:
_logger.warning("Rest day refused: %s", refusal)
_say(f"refused: {refusal}")
return 1
result = declare(day)
if not result.ok:
_logger.warning("Rest day refused: %s", result.reason)
_say(result.reason if result.ok else f"refused: {result.reason}")
return 0 if result.ok else 1

View File

@ -12,9 +12,10 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta
import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from screen_locker._log_mixin import write_signed_entry
from screen_locker._walk_tcx import walk_day, walk_shortfall
if TYPE_CHECKING:
from collections.abc import Callable
@ -27,6 +28,24 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__)
def _record(
log_file: Path,
date_str: str,
workout_data: dict[str, Any],
on_ingested: OnIngestedCallback | None,
) -> bool:
"""Append one verified RunnerUp entry and credit it; False if a duplicate."""
result = write_signed_entry(log_file, date_str, workout_data)
if not result.appended:
return False
_logger.info(
"Auto-filled RunnerUp entry for %s: %s", date_str, workout_data["source"]
)
if on_ingested is not None:
on_ingested(workout_data, result.prior_entries)
return True
class RunnerUpBackfillMixin:
"""Fills the log from RunnerUp exports for a date or a whole week."""
@ -53,10 +72,15 @@ class RunnerUpBackfillMixin:
Returns True if a new verified entry was appended for ``date_str``.
"""
walks: list[dict[str, Any]] = []
for remote in self._find_runnerup_exports_for_date(date_str):
data = self._pull_and_parse_tcx(remote)
if data is None:
continue
if data.get("walking"):
# Never one walk at a time: a day's walks are summed below.
walks.append(data)
continue
status, msg = self._validate_runnerup_data(data)
if status != "verified":
_logger.warning(
@ -72,15 +96,43 @@ class RunnerUpBackfillMixin:
"source": f"Auto-scanned: {msg}",
"distance_km": round(data["distance_m"] / 1000, 2),
"duration_minutes": round(data["duration_seconds"] / 60, 1),
"completed_at": data.get("ended_at"),
}
result = write_signed_entry(log_file, date_str, workout_data)
if not result.appended:
return False
_logger.info("Auto-filled RunnerUp entry for %s: %s", date_str, msg)
if on_ingested is not None:
on_ingested(workout_data, result.prior_entries)
return True
return False
return _record(log_file, date_str, workout_data, on_ingested)
return self._try_fill_walks(date_str, walks, log_file, on_ingested)
@staticmethod
def _try_fill_walks(
date_str: str,
walks: list[dict[str, Any]],
log_file: Path,
on_ingested: OnIngestedCallback | None,
) -> bool:
"""Append the day's walking as its RunnerUp workout, once it adds up.
Same type and so the same ``runnerup_verified:{date}`` slot as a run:
a walk and a run on one day are one RunnerUp credit, exactly as two
runs already were.
"""
if not walks:
return False
day = walk_day(walks)
if not day.qualifies:
_logger.warning(
"RunnerUp walks for %s do not count yet: %s",
date_str,
walk_shortfall(day),
)
return False
workout_data = {
"type": "runnerup_verified",
"activity": "walking",
"source": f"Auto-scanned: {day.message()}",
"distance_km": round(day.distance_km, 2),
"duration_minutes": round(day.moving_minutes, 1),
"completed_at": day.completed_at,
}
return _record(log_file, date_str, workout_data, on_ingested)
def _scan_and_fill_week_runnerup(
self, log_file: Path, on_ingested: OnIngestedCallback | None = None

View File

@ -17,6 +17,8 @@ import tempfile
from typing import Any
import xml.etree.ElementTree as ET
from screen_locker._walk_tcx import is_walking_export, track_summary
# TCX XML namespace used by Garmin/RunnerUp.
_TCX_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
@ -48,8 +50,9 @@ class RunnerUpTcxMixin:
phone path and goes through ``adb pull``. Returns the activity dict
or None.
"""
name = Path(remote_path).name
if Path(remote_path).is_file():
return self._parse_tcx(remote_path)
return _mark_walk(self._parse_tcx(remote_path), name)
tmp_dir = tempfile.mkdtemp(prefix="runnerup_tcx_")
local_path = str(Path(tmp_dir) / "activity.tcx")
try:
@ -57,7 +60,7 @@ class RunnerUpTcxMixin:
if not ok or not Path(local_path).exists():
_logger.info("Failed to pull TCX file: %s", remote_path)
return None
return self._parse_tcx(local_path)
return _mark_walk(self._parse_tcx(local_path), name)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
@ -101,4 +104,13 @@ class RunnerUpTcxMixin:
"sport": sport_int,
"duration_seconds": int(total_seconds),
"distance_m": total_distance,
"walking": is_walking_export(Path(tcx_path).name, sport_str),
**track_summary(activity),
}
def _mark_walk(data: dict[str, Any] | None, name: str) -> dict[str, Any] | None:
"""Flag a walk by its ORIGINAL file name: an adb pull renames the copy."""
if data is not None:
data["walking"] = data["walking"] or is_walking_export(name, "")
return data

View File

@ -23,6 +23,7 @@ from screen_locker._runnerup_backfill import RunnerUpBackfillMixin
from screen_locker._runnerup_db import RunnerUpDbMixin
from screen_locker._runnerup_tcx import RunnerUpTcxMixin
from screen_locker._time_check import check_clock_skew
from screen_locker._walk_tcx import walk_day, walk_shortfall
_logger = logging.getLogger(__name__)
@ -121,16 +122,26 @@ class RunnerUpVerificationMixin(
return None
# Try each file; return the best result (verified > validation error).
# Walks never qualify one file at a time: they are summed below.
best: tuple[str, str] | None = None
walks: list[dict[str, Any]] = []
for remote in exports:
data = self._pull_and_parse_tcx(remote)
if data is None:
continue
if data.get("walking"):
walks.append(data)
continue
status, msg = self._validate_runnerup_data(data)
if status == "verified":
return status, msg
if best is None:
best = (status, msg)
if walks:
day = walk_day(walks)
if day.qualifies:
return "verified", day.message()
best = best or ("too_short", walk_shortfall(day))
# All files found but none passed validation.
return best or (

View File

@ -2,18 +2,21 @@
from __future__ import annotations
from datetime import date
import logging
import subprocess
from typing import TYPE_CHECKING
import earned_time
from screen_locker._constants import (
ADJUST_SHUTDOWN_SCRIPT,
SHUTDOWN_CONFIG_FILE,
)
from screen_locker._day import today_str
from screen_locker._earned import hhmm, span
from screen_locker._earned import ceiling, first_minutes, hhmm, span
from screen_locker._grace_floor import GraceFloorMixin, plan_absorb, save_lift
from screen_locker._shutdown_sick_state import SickDayStateMixin
from screen_locker._workout_credit import FIRST_WORKOUT_BONUS_MINUTES
if TYPE_CHECKING:
from pathlib import Path
@ -29,8 +32,6 @@ _MINUTES_PER_HOUR = 60
# A sick day moves shutdown an hour earlier, but never before 18:00.
_SICK_DAY_STEP = 60
_SICK_DAY_FLOOR = 18 * _MINUTES_PER_HOUR
# The workout reward stops at 23:00 (adjust_shutdown_schedule.sh's ceiling).
_WORKOUT_CEILING = 23 * _MINUTES_PER_HOUR
# Extra bonuses may name midnight; the helper still clamps them to 23:00.
_MIDNIGHT = 24 * _MINUTES_PER_HOUR
@ -69,7 +70,7 @@ def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
return mon_wed, thu_sun, morning_end
class ShutdownMixin(SickDayStateMixin):
class ShutdownMixin(GraceFloorMixin, SickDayStateMixin):
"""Mixin providing shutdown schedule adjustment functionality."""
def _apply_earlier_shutdown(self, today: str) -> bool:
@ -107,24 +108,29 @@ class ShutdownMixin(SickDayStateMixin):
return False
def _adjust_shutdown_time_later(self) -> bool:
"""Push shutdown later by the first workout's reward, capped at 23:00.
"""Push shutdown later by the first workout's reward, capped at the ceiling.
Returns True if successful, False otherwise.
"""
day = date.fromisoformat(today_str())
bonus, lift = plan_absorb(first_minutes(earned_time.WORKOUT, day))
try:
config_values = self._read_shutdown_config()
if config_values is None:
return False
mon_wed, thu_sun, morning_end = config_values
return self._write_shutdown_config(
min(_WORKOUT_CEILING, mon_wed + FIRST_WORKOUT_BONUS_MINUTES),
min(_WORKOUT_CEILING, thu_sun + FIRST_WORKOUT_BONUS_MINUTES),
ok = self._write_shutdown_config(
min(ceiling(day), mon_wed + bonus),
min(ceiling(day), thu_sun + bonus),
morning_end,
restore=True,
)
except (OSError, ValueError) as e:
_logger.warning("Failed to adjust shutdown time for workout: %s", e)
return False
if ok:
save_lift(lift)
return ok
def _adjust_shutdown_time_by(self, extra_minutes: int) -> bool:
"""Push shutdown later by *extra_minutes*, capped at 24:00 (midnight).
@ -135,14 +141,16 @@ class ShutdownMixin(SickDayStateMixin):
Returns True if successful, False otherwise.
"""
# Paid out of the grace lift first, so the floor is never stacked on.
moved, lift = plan_absorb(extra_minutes)
try:
config_values = self._read_shutdown_config()
if config_values is None:
return False
mw, ts, morning = config_values
return self._write_shutdown_config(
min(_MIDNIGHT, mw + extra_minutes),
min(_MIDNIGHT, ts + extra_minutes),
ok = self._write_shutdown_config(
min(_MIDNIGHT, mw + moved),
min(_MIDNIGHT, ts + moved),
morning,
restore=True,
)
@ -151,6 +159,9 @@ class ShutdownMixin(SickDayStateMixin):
"Failed to adjust shutdown time by %s: %s", span(extra_minutes), e
)
return False
if ok:
save_lift(lift)
return ok
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
"""Read shutdown config as (mon_wed, thu_sun, morning_end) minutes, or None."""

View File

@ -37,12 +37,14 @@ from screen_locker._bonus_lock import bonus_lock
from screen_locker._day import today_str
from screen_locker._earned import (
earned_today,
flat_answers,
first_minutes,
flat_earners,
hhmm,
span,
)
from screen_locker._grace_floor import save_lift
from screen_locker._log_io import load_workout_log
from screen_locker._shutdown_target import gather
from screen_locker._weekly_check import count_day_credits
if TYPE_CHECKING:
@ -153,16 +155,9 @@ def _reset(
if _load_state(state_file).get("last_reset_date") == today:
return False
answers: dict[str, int | bool | None] = dict(flat_answers())
answers[earned_time.WORKOUT.name] = (
today_credit_count(log_file, today) if log_file is not None else 0
)
# The registry is passed explicitly: flat_answers iterated this same
# tuple, so the earners asked and the earners summed can never differ.
day = earned_time.resolve(
answers, datetime.now().astimezone().date(), earned_time.EARNERS
)
target = day.shutdown_minutes
derived = gather(date.fromisoformat(today), log_file)
day = derived.resolution
target = derived.minutes
# Preserve the morning end from the live config.
config = mixin._read_shutdown_config()
@ -174,6 +169,8 @@ def _reset(
return False
_clear_sick_day_state(sick_day_state_file)
# Later additions are paid out of the grace lift first (see _grace_floor).
save_lift(derived.lift)
# The flat bonuses are stamped here too: the reset already included them,
# so the live pass below must not add them a second time today.
@ -187,10 +184,12 @@ def _reset(
f"{span(t.shutdown_minutes)} {t.earner.label}" for t in day.terms
)
_logger.info(
"Daily base reset: %s (base %s + %s already earned today).",
"Daily base reset: %s (base %s + %s already earned today%s%s).",
hhmm(target),
hhmm(day.base.shutdown_minutes),
earned,
"; rest day" if derived.rest_day else "",
f"; grace floor {hhmm(derived.grace)}" if derived.grace is not None else "",
)
return True
@ -223,7 +222,7 @@ def _apply_flat_bonus(
return False
if not answer:
return False
minutes = earner.shutdown_minutes
minutes = first_minutes(earner, date.fromisoformat(today))
if not mixin._adjust_shutdown_time_by(minutes):
_logger.warning("%s bonus: failed to write shutdown config.", earner.label)
return False
@ -242,3 +241,5 @@ def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None:
with bonus_lock(state_file):
for earner in flat_earners():
_apply_flat_bonus(state_file, mixin, earner)
# Last, so the floor compares against everything earned so far.
mixin._apply_grace_floor(getattr(mixin, "log_file", None))

View File

@ -0,0 +1,133 @@
"""Tonight's shutdown, derived from scratch: earned time, rest day, grace floor.
:func:`derive` is pure, and is the one formula behind both the daily reset
(:mod:`screen_locker._shutdown_base`) and the read-only preview
(``scripts/preview_shutdown.py``), so a dry run exercises the real sum::
earned = earned_time.resolve(answers) # base + every earner, capped
target = min(ceiling, max(earned, grace floor))
A declared rest day answers the workout with at least one unit, i.e. its
first-unit bonus; a real workout on top of it earns only what a further unit
earns. :func:`gather` reads today's real inputs; it writes nothing.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import earned_time
from screen_locker._earned import ceiling, flat_answers
from screen_locker._grace_floor import first_done_at, grace_for, sick_on
from screen_locker._log_io import load_workout_log
from screen_locker._rest_day import is_rest_day
from screen_locker._weekly_check import count_day_credits
if TYPE_CHECKING:
from collections.abc import Mapping
from datetime import date
from pathlib import Path
@dataclass(frozen=True)
class Target:
"""One day's derived shutdown and what made it.
Attributes:
resolution: ``earned_time``'s sum (base + terms, capped).
rest_day: Whether the day is a declared rest day.
first_done_at: Unix time the day's first earner was done, if any.
grace: The grace floor in minutes, or ``None``.
minutes: The shutdown to write, minutes after midnight.
"""
resolution: earned_time.Resolution
rest_day: bool
first_done_at: float | None
grace: int | None
minutes: int
@property
def earned(self) -> int:
"""The earned shutdown, before the grace floor."""
return self.resolution.shutdown_minutes
@property
def lift(self) -> int:
"""How far the grace floor lifts the earned shutdown."""
return self.minutes - self.earned
def workout_units(count: int, *, rest_day: bool) -> int:
"""A rest day stands in for the first workout, never for a further one."""
return max(count, 1) if rest_day else count
@dataclass(frozen=True)
class DayInputs:
"""Everything one day's shutdown is derived from.
Attributes:
day: The day.
flat: Each flat earner's answer (``None`` = could not check).
workout_credits: The log's distinct workout credits.
rest_day: Declared rest day.
first_done: Unix time the day's first earner was done, if any.
sick_day: Sick day: the sick-day shutdown wins, no grace floor.
"""
day: date
flat: Mapping[str, bool | None]
workout_credits: int = 0
rest_day: bool = False
first_done: float | None = None
sick_day: bool = False
def derive(inputs: DayInputs) -> Target:
"""The pure formula: earned time, then the grace floor, then the ceiling."""
day = inputs.day
answers: dict[str, int | bool | None] = dict(inputs.flat)
answers[earned_time.WORKOUT.name] = workout_units(
inputs.workout_credits, rest_day=inputs.rest_day
)
# The registry is passed explicitly: flat_answers iterated this same
# tuple, so the earners asked and the earners summed can never differ.
resolution = earned_time.resolve(answers, day, earned_time.EARNERS)
grace = None if inputs.sick_day else grace_for(inputs.first_done, day)
minutes = resolution.shutdown_minutes
if grace is not None:
minutes = min(ceiling(day), max(minutes, grace))
return Target(
resolution=resolution,
rest_day=inputs.rest_day,
first_done_at=inputs.first_done,
grace=grace,
minutes=minutes,
)
def day_credit_count(log_file: Path | None, day: date) -> int:
"""How many distinct workouts ``day``'s log counts (0 without a log)."""
if log_file is None:
return 0
iso = day.isoformat()
entries = load_workout_log(log_file).get(iso, [])
return count_day_credits(iso, [e for e in entries if isinstance(e, dict)])
def gather(day: date, log_file: Path | None) -> Target:
"""Derive ``day``'s target from the real ledgers, log and rest days."""
rest_day = is_rest_day(day)
return derive(
DayInputs(
day=day,
flat=flat_answers(),
workout_credits=day_credit_count(log_file, day),
rest_day=rest_day,
first_done=first_done_at(day, log_file, rest_day=rest_day),
sick_day=sick_on(day),
)
)

View File

@ -10,12 +10,15 @@ from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
from dataclasses import dataclass
from datetime import date
from typing import TYPE_CHECKING
from screen_locker._earned import span
import earned_time
from screen_locker._day import today_str
from screen_locker._earned import extra_minutes, span
from screen_locker._status_data import gather_status
from screen_locker._weekly_check import WEEKLY_WORKOUT_MINIMUM, count_weekly_workouts
from screen_locker._workout_credit import EXTRA_WORKOUT_BONUS_MINUTES
if TYPE_CHECKING:
from collections.abc import Callable
@ -84,7 +87,9 @@ def _backfill_week_and_apply_bonus(verifier: ScreenLocker) -> str | None:
if sl_filled:
new_count = count_weekly_workouts(verifier.log_file)
extra = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count + filled))
bonus = extra * EXTRA_WORKOUT_BONUS_MINUTES
# Day-aware: 0 per further unit on earned_time's ladder (2026-10-10).
today = date.fromisoformat(today_str())
bonus = extra * extra_minutes(earned_time.WORKOUT, today)
if bonus > 0:
verifier._adjust_shutdown_time_by(bonus)
filled += sl_filled

144
screen_locker/_walk_tcx.py Normal file
View File

@ -0,0 +1,144 @@
"""Walking in RunnerUp TCX exports: which files are walks, and their moving time.
A day's walking counts as the workout once its walks -- usually separate
commute legs, one file each -- add up to ``WALK_MIN_MOVING_MINUTES`` of
*moving* time.
**Which files are walks.** RunnerUp's WebDAV and File synchronisers both
export with ``ExportOptions.getDefault()``, whose TCX writer knows only
Running/Biking and writes ``Sport="Other"`` for a walk. The sport survives
only in the file name (``Sport.TapiriikType()``): ``..._Walking.tcx``. The
Strava export variant writes ``Sport="Walking"``, so that counts too.
``Other`` alone is not a walk: it is every sport RunnerUp cannot name.
**Moving time, not elapsed.** A lap's ``TotalTimeSeconds`` is wall-clock: one
real lap reads 101 min for 1 km of standing about. Moving time is summed over
consecutive trackpoint pairs whose speed is walking speed
(``WALK_SPEED_MPS``): standing still is too slow, a tram or a bus too fast.
A pair further apart than ``WALK_MAX_GAP_SECONDS`` is a GPS dropout whose
motion is unknown and earns nothing -- the walk is never credited for time
nobody saw.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import itertools
import logging
from typing import TYPE_CHECKING, Any
from screen_locker._constants import (
WALK_MAX_GAP_SECONDS,
WALK_MIN_MOVING_MINUTES,
WALK_SPEED_MPS,
)
if TYPE_CHECKING:
from collections.abc import Iterable
import xml.etree.ElementTree as ET
_logger = logging.getLogger(__name__)
_TCX_NS = "{http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2}"
_WALKING = "Walking"
def is_walking_export(name: str, sport: str) -> bool:
"""Whether a TCX export is a walk, from its file name or its Sport tag."""
return name.endswith(f"_{_WALKING}.tcx") or sport == _WALKING
def _stamp(raw: str | None) -> float | None:
if not raw:
return None
try:
return datetime.fromisoformat(raw).timestamp()
except ValueError:
_logger.warning("TCX trackpoint has an unparsable time %r; skipped", raw)
return None
def _trackpoints(activity: ET.Element) -> list[tuple[float, float]]:
"""``(unix time, cumulative metres)`` of every usable trackpoint, in order."""
points: list[tuple[float, float]] = []
for point in activity.iter(f"{_TCX_NS}Trackpoint"):
when = _stamp(point.findtext(f"{_TCX_NS}Time"))
metres = point.findtext(f"{_TCX_NS}DistanceMeters")
if when is None or not metres:
continue
points.append((when, float(metres)))
points.sort()
return points
def track_summary(activity: ET.Element) -> dict[str, Any]:
"""Moving seconds and end time of one activity, from its trackpoints."""
points = _trackpoints(activity)
slow, fast = WALK_SPEED_MPS
moving = 0.0
for (t0, d0), (t1, d1) in itertools.pairwise(points):
gap = t1 - t0
if 0 < gap <= WALK_MAX_GAP_SECONDS and slow <= (d1 - d0) / gap <= fast:
moving += gap
return {
"activity_id": activity.findtext(f"{_TCX_NS}Id") or "",
"moving_seconds": moving,
"ended_at": points[-1][0] if points else None,
}
@dataclass(frozen=True)
class WalkDay:
"""A day's walks, summed.
Attributes:
legs: Distinct walks (deduplicated by the activity's ``Id``: the
WebDAV copy and the phone's copy are the same walk).
moving_minutes: Their summed moving time.
distance_km: Their summed distance.
completed_at: Unix time the walk that crossed the bar ended -- when
the workout was actually done -- or ``None`` if it never did.
"""
legs: int
moving_minutes: float
distance_km: float
completed_at: float | None
@property
def qualifies(self) -> bool:
"""Whether the day's walking counts as its workout."""
return self.moving_minutes >= WALK_MIN_MOVING_MINUTES
def message(self) -> str:
"""The verification line, in the RunnerUp verifier's own style."""
return (
f"Walking: {self.legs} walk(s), {self.distance_km:.1f} km, "
f"{self.moving_minutes:.0f} min moving"
)
def walk_shortfall(day: WalkDay) -> str:
"""Why a day's walking did not count yet."""
return f"{day.message()} -- need {WALK_MIN_MOVING_MINUTES}+ min moving"
def walk_day(walks: Iterable[dict[str, Any]]) -> WalkDay:
"""Sum parsed walk exports (``_parse_tcx`` dicts) into one :class:`WalkDay`."""
unique: dict[str, dict[str, Any]] = {}
for walk in walks:
unique.setdefault(str(walk.get("activity_id") or id(walk)), walk)
ordered = sorted(unique.values(), key=lambda w: w.get("ended_at") or 0.0)
moving = 0.0
completed_at: float | None = None
for walk in ordered:
moving += float(walk.get("moving_seconds", 0.0))
if completed_at is None and moving / 60 >= WALK_MIN_MOVING_MINUTES:
completed_at = walk.get("ended_at")
return WalkDay(
legs=len(ordered),
moving_minutes=moving / 60,
distance_km=sum(float(w.get("distance_m", 0.0)) for w in ordered) / 1000,
completed_at=completed_at,
)

View File

@ -10,12 +10,16 @@ miss (see ``project-lock-disabled-pending-manual-log`` memory).
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
import logging
from typing import TYPE_CHECKING
import earned_time
from screen_locker import _sick_tracker
from screen_locker._day import today_str
from screen_locker._earned import extra_minutes
from screen_locker._rest_day import is_rest_day
from screen_locker._weekly_check import (
COUNTED_WORKOUT_TYPES,
PC_WORKOUT_TYPE,
@ -30,6 +34,8 @@ _logger = logging.getLogger(__name__)
# The shutdown reward for a day's workouts, from the shared registry
# (``earned_time.WORKOUT``): the first counted workout pushes shutdown later by
# FIRST_WORKOUT_BONUS_MINUTES, every further one by EXTRA_WORKOUT_BONUS_MINUTES.
# Those are the pre-ladder fields; from earned_time's ladder (2026-10-10) the
# live paths ask the day-aware _earned.first_minutes / extra_minutes instead.
# The live credit paths below and the daily base reset (``_shutdown_base``)
# both derive from the registry, so a day's earned time can be recomputed
# from the log instead of being lost when the credit landed before the reset.
@ -124,11 +130,23 @@ class WorkoutCreditMixin:
shutdown_adjusted = False
extra_bonus_delta = 0
if first_counted_today:
day = date.fromisoformat(today_str())
# A rest day already paid the first unit (the daily reset counts it):
# the first real workout on it earns nothing, a second one what a
# further unit earns -- the reset's own max(credits, 1) rule.
rest_paid = first_counted_today and is_rest_day(day)
if rest_paid:
_logger.info("Rest day: its first-unit bonus is already in tonight's time")
extra = extra_minutes(earned_time.WORKOUT, day)
if first_counted_today and not rest_paid:
shutdown_adjusted = self._try_adjust_shutdown_for_workout()
elif self.workout_data.get("type") in COUNTED_WORKOUT_TYPES:
elif (
not first_counted_today
and extra
and self.workout_data.get("type") in COUNTED_WORKOUT_TYPES
):
old_cfg = self._read_shutdown_config()
if old_cfg and self._adjust_shutdown_time_by(EXTRA_WORKOUT_BONUS_MINUTES):
if old_cfg and self._adjust_shutdown_time_by(extra):
new_cfg = self._read_shutdown_config()
if new_cfg:
extra_bonus_delta = new_cfg[1] - old_cfg[1]

View File

@ -0,0 +1,238 @@
"""The workout ledger: signed credit rows, the workout's side of ``earned_time``.
Every other earner publishes its credits as HMAC-signed rows in a ledger that
``earned_time`` reads (``first_credit_at``, ``credit_units``). This is the
workout's: ``~/.local/share/workout_locker/ledger.json``, one row per
* workout credit slot in ``log.json`` -- RunnerUp (a run, or the day's walks
summed), StrongLifts (phone or PC session), manual, any lock-screen or
verify path -- appended by the one log write chokepoint
(``_log_mixin.write_signed_entry``), so no path can credit without it; and
* declared rest day -- appended when it is declared.
Row::
{"kind": "credit", "entry_id": ..., "day": "YYYY-MM-DD",
"detail": {"completed_at": "<unix>", "source": "runnerup_tcx" |
"stronglifts" | "manual" | "rest_day", ["declared_at": ...]},
"hmac": ...}
``credit_units(WORKOUT)`` therefore equals the log's own credit count: one row
per ``credit_key`` slot, idempotent on ``entry_id`` (the slot's first entry's
``workout_id``), so a re-scan or a second ingestion path writes nothing.
``completed_at`` is the activity's real end where the log knows it
(:func:`entry_done_at`). A rest day's is local midnight at the start of that
day, and its ``declared_at`` (earlier, by construction) proves it was planned.
"""
from __future__ import annotations
from datetime import date, datetime, time
import json
import logging
import os
from pathlib import Path
import tempfile
from typing import TYPE_CHECKING, Any
import earned_time
from screen_locker import _earned
from screen_locker._bonus_lock import bonus_lock
from screen_locker._constants import WORKOUT_LEDGER_RELATIVE
from screen_locker._weekly_check import credit_key, day_workout_index
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping, Sequence
_logger = logging.getLogger(__name__)
# The ledger's ``detail.source`` per log type; anything else keeps its type.
_SOURCES = {
"runnerup_verified": "runnerup_tcx",
"phone_verified": "stronglifts",
"pc_workout_verified": "stronglifts",
"manual_workout": "manual",
}
_HHMM = len("HH:MM")
def ledger_path() -> Path:
"""Where the workout ledger lives (redirected with ``_earned.LEDGER_HOME``)."""
return _earned.LEDGER_HOME / WORKOUT_LEDGER_RELATIVE
def _as_stamp(raw: object, day: date) -> float | None:
"""A unix time from a number, a numeric string, ISO, or ``HH:MM`` on ``day``."""
if isinstance(raw, int | float):
return float(raw)
text = str(raw or "").strip()
if not text:
return None
try:
if text.replace(".", "", 1).isdigit():
return float(text)
if len(text) <= _HHMM:
hours, _, minutes = text.partition(":")
clock = time(int(hours), int(minutes))
return datetime.combine(day, clock).astimezone().timestamp()
return datetime.fromisoformat(text).astimezone().timestamp()
except ValueError:
_logger.warning("Unusable workout time %r; ignored", raw)
return None
def entry_done_at(entry: Mapping[str, Any], day: date) -> float | None:
"""When a logged workout ended; the moment it was logged as a last resort."""
real = _real_end(entry, day)
return real if real is not None else _as_stamp(entry.get("timestamp"), day)
def _real_end(entry: Mapping[str, Any], day: date) -> float | None:
"""When a logged workout actually ended, as unix seconds.
In order: the RunnerUp TCX end (``completed_at``), a manual workout's
``end_time``, a StrongLifts session's start (``sync_record_id``) plus its
``duration_minutes``; ``None`` when the entry records no end of its own.
"""
data = entry.get("workout_data")
data = data if isinstance(data, dict) else {}
for raw in (data.get("completed_at"), data.get("end_time")):
stamp = _as_stamp(raw, day)
if stamp is not None:
return stamp
if data.get("type") == "pc_workout_verified":
start = _as_stamp(data.get("sync_record_id"), day)
try:
minutes = float(str(data.get("duration_minutes", "")))
except ValueError:
_logger.warning("Session on %s has no usable duration", day)
minutes = None
if start is not None and minutes is not None:
return start + minutes * 60
return None
def credit_row(
day: str, entries: Sequence[Mapping[str, Any]], index: int
) -> dict[str, Any] | None:
"""The unsigned row for ``entries[index]``; ``None`` if it earns no credit.
One row per CREDIT SLOT (``_weekly_check.credit_key``), not per entry: the
two StrongLifts ingestion paths and a synced copy of an entry share their
slot, and so share the ``entry_id`` -- the slot's first entry's own
``workout_id`` -- and the second never writes a row.
"""
siblings = day_workout_index(entries)
slot = credit_key(day, index, entries[index], siblings)
if slot is None:
return None
members = [
e for i, e in enumerate(entries) if credit_key(day, i, e, siblings) == slot
]
owner = members[0]
data = owner.get("workout_data")
wtype = str(data.get("type")) if isinstance(data, dict) else ""
# The slot's real end, from whichever member recorded one (the PC session
# of a StrongLifts slot knows its duration, the phone copy does not).
when = date.fromisoformat(day)
ends = [t for e in members if (t := _real_end(e, when)) is not None]
done = min(ends) if ends else entry_done_at(owner, when)
if done is None:
_logger.warning("Workout on %s has no usable time; no ledger row", day)
return None
entry_id = owner.get("workout_id") or f"{slot[0]}:{slot[1]}"
return {
"kind": "credit",
"entry_id": str(entry_id),
"day": day,
"detail": {"completed_at": str(done), "source": _SOURCES.get(wtype, wtype)},
}
def rest_row(day: date, declared_at: float) -> dict[str, Any]:
"""The unsigned row for a declared rest day, stamped at its own midnight."""
midnight = datetime.combine(day, time.min).astimezone().timestamp()
return {
"kind": "credit",
"entry_id": f"rest:{day.isoformat()}",
"day": day.isoformat(),
"detail": {
"completed_at": str(midnight),
"declared_at": str(declared_at),
"source": "rest_day",
},
}
def existing_rows(path: Path) -> list[Any] | None:
"""The ledger's rows; ``[]`` if absent, ``None`` (logged) if malformed."""
if not path.exists():
return []
raw = json.loads(path.read_text(encoding="utf-8"))
rows = raw.get("entries") if isinstance(raw, dict) else None
if not isinstance(rows, list):
_logger.warning("Workout ledger %s has no entries array", path)
return None
return rows
def _atomic_write(path: Path, rows: list[Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".ledger-", suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump({"entries": rows}, handle, indent=2)
Path(tmp).replace(path)
def new_rows(
rows: Iterable[dict[str, Any]], existing: list[Any]
) -> list[dict[str, Any]]:
"""The rows whose ``entry_id`` is not among ``existing`` yet."""
seen = {r.get("entry_id") for r in existing if isinstance(r, dict)}
fresh: list[dict[str, Any]] = []
for row in rows:
if row["entry_id"] not in seen:
seen.add(row["entry_id"])
fresh.append(row)
return fresh
def append_rows(
rows: Iterable[dict[str, Any]],
path: Path | None = None,
key_file: Path | None = None,
) -> int:
"""Sign and append every row not already there. Returns how many were new.
Never raises: a ledger that cannot be written is logged loudly, and the
credit it mirrors (log.json) stands regardless.
"""
target = path or ledger_path()
# The same key binding ``_earned`` verifies the gates' ledgers with.
key_path = key_file or _earned.HMAC_KEY_FILE
try:
key = key_path.read_bytes().strip()
target.parent.mkdir(parents=True, exist_ok=True)
with bonus_lock(target):
existing = existing_rows(target)
if existing is None:
return 0
fresh = new_rows(rows, existing)
if not fresh:
return 0
for row in fresh:
row["hmac"] = earned_time.entry_signature(row, key)
_atomic_write(target, [*existing, *fresh])
except (OSError, ValueError) as exc:
_logger.warning("Workout ledger %s NOT updated: %s", target, exc)
return 0
_logger.info("Workout ledger: %d new credit row(s) in %s", len(fresh), target)
return len(fresh)
def record_log_entry(day: str, entries: Sequence[Mapping[str, Any]]) -> None:
"""Mirror the day's newest log entry into the ledger, if it earns a credit."""
row = credit_row(day, entries, len(entries) - 1)
if row is not None:
append_rows([row])

View File

@ -0,0 +1,86 @@
"""``--backfill-workout-ledger``: one-shot, idempotent ledger backfill.
python -m screen_locker.screen_lock --backfill-workout-ledger --dry-run
python -m screen_locker.screen_lock --backfill-workout-ledger
--from 2026-10-01 --to 2026-10-09 [--dry-run]
Writes a signed row for every workout credit slot already in ``log.json`` for
the days (default: today) and for every declared rest day in that range --
the credits made before the ledger existed. Rows already present (same
``entry_id``) are skipped, so running it twice writes nothing the second
time. ``--dry-run`` prints the rows it would add, unsigned, with a count per
source, and writes nothing.
"""
from __future__ import annotations
import argparse
from collections import Counter
from datetime import date, timedelta
import json
import sys
from typing import TYPE_CHECKING, Any
from screen_locker._day import today_str
from screen_locker._log_io import load_workout_log
from screen_locker._rest_day import rest_day_declarations
from screen_locker._workout_ledger import (
append_rows,
credit_row,
existing_rows,
ledger_path,
new_rows,
rest_row,
)
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
def backfill_rows(log_file: Path, first: date, last: date) -> list[dict[str, Any]]:
"""Unsigned rows for every credit slot and rest day from ``first`` to ``last``."""
logs = load_workout_log(log_file)
declared = rest_day_declarations()
rows: list[dict[str, Any]] = []
day = first
while day <= last:
iso = day.isoformat()
entries = [e for e in logs.get(iso, []) if isinstance(e, dict)]
for index in range(len(entries)):
row = credit_row(iso, entries, index)
if row is not None and row["entry_id"] not in {r["entry_id"] for r in rows}:
rows.append(row)
if day in declared:
rows.append(rest_row(day, declared[day]))
day += timedelta(days=1)
return rows
def run_backfill(log_file: Path, argv: Sequence[str]) -> int:
"""Backfill (or preview) the workout ledger. Returns a process exit code."""
parser = argparse.ArgumentParser(prog="screen-locker --backfill-workout-ledger")
parser.add_argument("--from", dest="first", default=None, help="YYYY-MM-DD")
parser.add_argument("--to", dest="last", default=None, help="YYYY-MM-DD")
parser.add_argument("--dry-run", action="store_true", help="print, write nothing")
args = parser.parse_args(list(argv))
today = date.fromisoformat(today_str())
first = date.fromisoformat(args.first) if args.first else today
last = date.fromisoformat(args.last) if args.last else first
rows = backfill_rows(log_file, first, last)
path = ledger_path()
if args.dry_run:
existing = existing_rows(path)
fresh = new_rows(rows, existing or [])
for row in fresh:
sys.stdout.write(json.dumps(row) + "\n")
per_source = Counter(r["detail"]["source"] for r in fresh)
sys.stdout.write(
f"ledger {path} ({'present' if path.exists() else 'missing'}), "
f"{first}..{last}: {len(rows)} candidate row(s), {len(fresh)} new; "
f"per source: {dict(sorted(per_source.items()))}\n"
)
return 0
added = append_rows(rows, path)
sys.stdout.write(f"{added} new row(s) appended to {path}\n")
return 0

View File

@ -87,6 +87,17 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
"earned_hmac.key",
("_earned.HMAC_KEY_FILE",),
),
# Declared rest days: read by every workout credit and every reset, so an
# unredirected test would see the user's real rest days.
(
"rest_days.json",
("_constants.REST_DAY_FILE", "_rest_day.REST_DAY_FILE"),
),
# Today's grace-floor lift; every shutdown add reads and writes it.
(
"grace_floor.json",
("_constants.GRACE_STATE_FILE", "_grace_floor.GRACE_STATE_FILE"),
),
# wake-alarm's workday-stick file. A test that forgot this would read
# the developer's real penalty state.
(

View File

@ -0,0 +1,180 @@
"""The wiring around rest days, the grace lift and the ladder accessors.
Live workout credit on a rest day, lift absorption in the shutdown adds,
the day-aware ``_earned`` accessors on an ``earned_time`` without the ladder
API (0.3.0), and the new CLI flags.
"""
from __future__ import annotations
from datetime import date
import logging
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import earned_time
import pytest
from screen_locker import _cli, _earned, _grace_floor, _runnerup_tcx, _workout_credit
from screen_locker._shutdown import ShutdownMixin
from screen_locker._workout_credit import WorkoutCreditMixin
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_ISO = "2026-10-12" # a Monday
class Host(WorkoutCreditMixin, ShutdownMixin):
"""The real credit and add paths over an in-memory config."""
def __init__(self, tmp_path: Path, minutes: int, *, ok: bool = True) -> None:
self.config = (minutes, minutes, 300)
self.ok = ok
self.log_file = tmp_path / "log.json"
self.workout_data = {"type": "runnerup_verified"}
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
return self.config
def _write_shutdown_config(
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = False
) -> bool:
del restore
if self.ok:
self.config = (min(mon_wed, 1380), min(thu_sun, 1380), morning_end)
return self.ok
@pytest.fixture
def monday() -> Iterator[None]:
with (
patch("screen_locker._workout_credit.today_str", return_value=_ISO),
patch("screen_locker._shutdown.today_str", return_value=_ISO),
patch.object(_grace_floor, "today_str", return_value=_ISO),
):
yield
_COUNTED: list[dict[str, Any]] = [{"workout_data": {"type": "runnerup_verified"}}]
@pytest.mark.usefixtures("monday")
class TestRestDayCredit:
def test_the_first_real_workout_earns_nothing_more(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
host = Host(tmp_path, 20 * 60)
with (
patch.object(_workout_credit, "is_rest_day", return_value=True),
caplog.at_level(logging.INFO),
):
result = host._apply_credit_for_written_entry([])
assert host.config[0] == 20 * 60
assert not result.shutdown_adjusted
assert "already in tonight's time" in caplog.text
def test_a_second_one_earns_a_further_unit(self, tmp_path: Path) -> None:
host = Host(tmp_path, 20 * 60)
extra = _earned.extra_minutes(earned_time.WORKOUT, _grace_date())
with patch.object(_workout_credit, "is_rest_day", return_value=True):
result = host._apply_credit_for_written_entry(_COUNTED)
assert host.config[0] == 20 * 60 + extra
assert result.extra_bonus_delta == extra
def _grace_date() -> date:
return date.fromisoformat(_ISO)
@pytest.mark.usefixtures("monday")
class TestLiftAbsorption:
def test_adds_pay_from_the_lift_first(self, tmp_path: Path) -> None:
_grace_floor.save_lift(30)
host = Host(tmp_path, 19 * 60 + 55)
assert host._adjust_shutdown_time_by(25)
assert host.config[0] == 19 * 60 + 55
assert host._adjust_shutdown_time_by(25)
assert host.config[0] == 20 * 60 + 15
def test_a_failed_write_keeps_the_lift(self, tmp_path: Path) -> None:
_grace_floor.save_lift(30)
host = Host(tmp_path, 1195, ok=False)
assert not host._adjust_shutdown_time_by(25)
assert not host._adjust_shutdown_time_later()
assert _grace_floor.plan_absorb(0) == (0, 30)
def test_the_first_workout_is_day_aware(self, tmp_path: Path) -> None:
host = Host(tmp_path, 1140)
assert host._adjust_shutdown_time_later()
first = _earned.first_minutes(earned_time.WORKOUT, _grace_date())
assert host.config[0] == 1140 + first
class TestPreLadderEarnedTime:
"""On 0.3.0 the accessors fall back to the raw fields, and say so once."""
def test_raw_fields_without_the_ladder_api(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
for name in (
"shutdown_minutes_for",
"extra_shutdown_minutes_for",
"shutdown_ceiling_for",
"first_credit_at",
):
monkeypatch.delattr(earned_time, name, raising=False)
day = _grace_date()
workout = earned_time.WORKOUT
assert _earned.first_minutes(workout, day) == workout.shutdown_minutes
assert _earned.extra_minutes(workout, day) == workout.extra_shutdown_minutes
assert _earned.ceiling(day) == earned_time.SHUTDOWN_CEILING_MINUTES
_earned._warn_no_first_credit.cache_clear()
with caplog.at_level(logging.WARNING):
assert _earned.first_credit_time(earned_time.ANKI, day) is None
assert _earned.first_credit_time(earned_time.ANKI, day) is None
assert caplog.text.count("has no first_credit_at") == 1
def test_the_ladder_api_reads_the_ledger(self, tmp_path: Path) -> None:
if not hasattr(earned_time, "first_credit_at"):
pytest.skip("installed earned_time predates first_credit_at")
assert _earned.first_credit_time(earned_time.ANKI, _grace_date()) is None
def test_a_failed_tcx_parse_is_not_marked() -> None:
assert _runnerup_tcx._mark_walk(None, "x_Walking.tcx") is None
class _Bare:
"""What ``_headless_locker`` builds on: any class with a ``__dict__``."""
log_file: Path
workout_data: dict[str, str]
class TestCliFlags:
def test_declare_rest_day_dispatches_the_rest_of_argv(self) -> None:
with (
patch.object(_cli.logging, "basicConfig"),
patch.object(_cli, "run_declare_rest_day", return_value=0) as run,
patch.object(_cli.sys, "exit", side_effect=SystemExit) as sys_exit,
pytest.raises(SystemExit),
):
_cli.main(_Bare, ["screen_lock.py", "--declare-rest-day", "tomorrow"])
run.assert_called_once_with(["tomorrow"])
sys_exit.assert_called_once_with(0)
def test_backfill_dispatches_with_the_log(self) -> None:
with (
patch.object(_cli.logging, "basicConfig"),
patch.object(_cli, "run_backfill", return_value=0) as run,
patch.object(_cli.sys, "exit", side_effect=SystemExit),
pytest.raises(SystemExit),
):
_cli.main(
_Bare, ["screen_lock.py", "--backfill-workout-ledger", "--dry-run"]
)
log_file, argv = run.call_args.args
assert log_file.name == "log.json"
assert argv == ["--dry-run"]

View File

@ -95,8 +95,11 @@ class TestLedgerFile:
assert not any(ledger_file(e).exists() for e in flat_earners())
def test_earner_without_a_ledger_raises(self) -> None:
bare = earned_time.Earner(
name="bare", label="Bare", gaming_minutes=0, shutdown_minutes=0
)
with pytest.raises(ValueError, match="has no ledger"):
ledger_file(earned_time.WORKOUT)
ledger_file(bare)
class TestFlatEarners:

View File

@ -137,7 +137,7 @@ class TestBonusLock:
) as lock,
patch("screen_locker._shutdown_base.flat_earners", return_value=()),
):
apply_flat_bonuses_if_new(state, object())
apply_flat_bonuses_if_new(state, MagicMock())
lock.assert_called_once_with(state)
assert seen == [True]

View File

@ -0,0 +1,207 @@
"""Tests for the grace floor, its lift bookkeeping and the derived target."""
from __future__ import annotations
import dataclasses
from datetime import date, datetime, time, timedelta
import json
import logging
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import earned_time
import pytest
from screen_locker import _earned, _grace_floor, _sick_tracker
from screen_locker._grace_floor import (
GraceFloorMixin,
first_done_at,
grace_for,
plan_absorb,
save_lift,
sick_on,
workout_done_at,
)
if TYPE_CHECKING:
from pathlib import Path
_DAY = date(2026, 10, 10)
_ISO = _DAY.isoformat()
_FLAT = {"leetcode": False, "reading": False, "anki": False, "automation": False}
def at(hhmm: str, day: date = _DAY) -> float:
hours, minutes = map(int, hhmm.split(":"))
return datetime.combine(day, time(hours, minutes)).astimezone().timestamp()
def log_with(tmp_path: Path, entries: list[dict[str, Any]]) -> Path:
log = tmp_path / "log.json"
log.write_text(json.dumps({_ISO: entries}))
return log
class TestWhenTheDayStarted:
def test_the_first_counted_workout_by_its_own_end(self, tmp_path: Path) -> None:
log = log_with(
tmp_path,
[
{"workout_data": {"type": "relaxed_day_skip"}, "timestamp": "x"},
{"workout_data": {"type": "manual_workout", "end_time": "18:40"}},
{
"workout_data": {
"type": "runnerup_verified",
"completed_at": at("17:00"),
}
},
],
)
assert workout_done_at(log, _DAY) == at("17:00")
def test_no_workout_is_none(self, tmp_path: Path) -> None:
assert workout_done_at(log_with(tmp_path, []), _DAY) is None
def test_the_earliest_earner_on_the_day_wins(self, tmp_path: Path) -> None:
log = log_with(
tmp_path,
[{"workout_data": {"type": "manual_workout", "end_time": "19:30"}}],
)
times = {"anki": at("18:55"), "leetcode": at("23:00", _DAY - timedelta(1))}
with patch.object(
_grace_floor,
"first_credit_time",
side_effect=lambda e, _d: times.get(e.name),
):
assert first_done_at(_DAY, log) == at("18:55")
assert first_done_at(_DAY, None) == at("18:55")
def test_a_rest_day_skips_the_workout_ledger(self) -> None:
"""Its rest row is stamped at midnight, which is not a completion."""
ledgered = dataclasses.replace(earned_time.WORKOUT, ledger="w.json")
with (
patch.object(earned_time, "WORKOUT", ledgered),
patch.object(_grace_floor, "first_credit_time", return_value=at("00:00")),
):
assert first_done_at(_DAY, None) == at("00:00")
with patch.object(_grace_floor, "flat_earners", return_value=()):
assert first_done_at(_DAY, None, rest_day=True) is None
def test_without_a_workout_ledger_only_the_log_answers(self) -> None:
bare = earned_time.Earner(
name="workout", label="workout", gaming_minutes=0, shutdown_minutes=0
)
with (
patch.object(earned_time, "WORKOUT", bare),
patch.object(_grace_floor, "first_credit_time", return_value=None),
):
assert first_done_at(_DAY, None) is None
class TestGraceFor:
def test_an_hour_after_the_first_task(self) -> None:
assert grace_for(at("18:55"), _DAY) == 19 * 60 + 55
def test_capped_at_the_ceiling(self) -> None:
assert grace_for(at("22:40"), _DAY) == _earned.ceiling(_DAY)
def test_nothing_done_or_another_day_is_no_floor(self) -> None:
assert grace_for(None, _DAY) is None
assert grace_for(at("12:00", _DAY - timedelta(1)), _DAY) is None
class TestLift:
def test_absorb_pays_from_the_lift_first(self) -> None:
with patch.object(_grace_floor, "today_str", return_value=_ISO):
assert plan_absorb(30) == (30, 0)
save_lift(45)
assert plan_absorb(30) == (0, 15)
assert plan_absorb(60) == (15, 0)
def test_yesterdays_lift_is_spent(self) -> None:
with patch.object(_grace_floor, "today_str", return_value="2026-10-09"):
save_lift(45)
with patch.object(_grace_floor, "today_str", return_value=_ISO):
assert plan_absorb(30) == (30, 0)
def test_an_unreadable_state_is_no_lift(
self, caplog: pytest.LogCaptureFixture
) -> None:
_grace_floor.GRACE_STATE_FILE.write_text("{bad")
with caplog.at_level(logging.WARNING):
assert plan_absorb(10) == (10, 0)
assert "unreadable" in caplog.text
def test_a_failed_save_is_logged(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING):
save_lift(5, tmp_path / "missing-dir" / "g.json")
assert "Could not record the grace lift" in caplog.text
class Host(GraceFloorMixin):
"""The mixin over an in-memory config."""
def __init__(self, config: tuple[int, int, int] | None, *, ok: bool = True) -> None:
self.config = config
self.ok = ok
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
return self.config
def _write_shutdown_config(
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = False
) -> bool:
assert restore
if self.ok:
self.config = (mon_wed, thu_sun, morning_end)
return self.ok
@pytest.fixture
def saturday() -> Any:
with patch.object(_grace_floor, "today_str", return_value=_ISO):
yield
@pytest.mark.usefixtures("saturday")
class TestApply:
def test_lifts_to_the_floor_and_records_the_lift(self) -> None:
host = Host((19 * 60 + 25, 19 * 60 + 25, 300))
assert host._apply_grace_floor(None, first_done=at("18:55")) is True
assert host.config == (19 * 60 + 55, 19 * 60 + 55, 300)
assert plan_absorb(0) == (0, 30)
def test_already_above_the_floor(self) -> None:
host = Host((21 * 60, 21 * 60, 300))
assert host._apply_grace_floor(None, first_done=at("18:55")) is False
def test_no_first_task_no_floor(self) -> None:
with patch.object(_grace_floor, "first_done_at", return_value=None):
assert Host((1140, 1140, 300))._apply_grace_floor(None) is False
def test_unreadable_config(self, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
assert Host(None)._apply_grace_floor(None, first_done=at("18:55")) is False
assert "config unreadable" in caplog.text
def test_failed_write(self, caplog: pytest.LogCaptureFixture) -> None:
host = Host((1140, 1140, 300), ok=False)
with caplog.at_level(logging.WARNING):
assert host._apply_grace_floor(None, first_done=at("18:55")) is False
assert "failed to write" in caplog.text
assert plan_absorb(0) == (0, 0)
def test_a_sick_day_has_no_floor(self) -> None:
"""The user's call: the sick-day shutdown wins."""
_grace_floor_history([_ISO])
assert sick_on(_DAY)
host = Host((18 * 60, 18 * 60, 300))
assert host._apply_grace_floor(None, first_done=at("18:55")) is False
assert host.config == (18 * 60, 18 * 60, 300)
def _grace_floor_history(days: list[str]) -> None:
_sick_tracker.SICK_HISTORY_FILE.write_text(json.dumps({"sick_days": days}))

View File

@ -0,0 +1,219 @@
"""Tests for ``_rest_day`` and its CLI: declared ahead, two a week, signed."""
from __future__ import annotations
from datetime import date, datetime, time
import json
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
import earned_time
import pytest
from screen_locker import _earned, _rest_day
from screen_locker._rest_day import declare, is_rest_day, rest_day_declarations
from screen_locker._rest_day_cli import run_declare_rest_day
from screen_locker._workout_ledger import ledger_path
from screen_locker.tests._earned_fixtures import KEY, signing_key
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_EVE = datetime.combine(date(2026, 10, 9), time(18, 55)).astimezone()
_SAT, _SUN, _MON = date(2026, 10, 10), date(2026, 10, 11), date(2026, 10, 12)
@pytest.fixture
def key(tmp_path: Path) -> Iterator[Path]:
with signing_key(tmp_path) as path:
yield path
def rows() -> list[dict[str, object]]:
return json.loads(_rest_day.REST_DAY_FILE.read_text())["entries"]
def write(entries: object) -> None:
_rest_day.REST_DAY_FILE.write_text(json.dumps({"entries": entries}))
@pytest.mark.usefixtures("key")
class TestDeclare:
def test_a_future_day_is_signed_and_mirrored_to_the_ledger(self) -> None:
result = declare(_SAT, now=_EVE)
assert result.ok
assert result.reason == "2026-10-10 is a rest day (1 left in ISO week 41)"
(row,) = rows()
assert earned_time.verified(row, KEY)
assert row["declared_on"] == "2026-10-09"
(credit,) = json.loads(ledger_path().read_text())["entries"]
assert credit["entry_id"] == "rest:2026-10-10"
assert credit["detail"]["source"] == "rest_day"
assert float(credit["detail"]["declared_at"]) == _EVE.timestamp()
midnight = datetime.combine(_SAT, time.min).astimezone().timestamp()
assert float(credit["detail"]["completed_at"]) == midnight
assert is_rest_day(_SAT)
@pytest.mark.parametrize("day", [date(2026, 10, 9), date(2026, 10, 1)])
def test_today_and_the_past_are_refused(self, day: date) -> None:
result = declare(day, now=_EVE)
assert not result.ok
assert "not in the future" in result.reason
assert not _rest_day.REST_DAY_FILE.exists()
def test_two_per_iso_week_then_refused(self) -> None:
assert declare(_SAT, now=_EVE).ok
assert declare(_SUN, now=_EVE).reason.endswith("(0 left in ISO week 41)")
assert declare(_SUN, now=_EVE).reason == "2026-10-11 is already a rest day"
third = declare(date(2026, 10, 8 + 1), now=datetime(2026, 10, 8).astimezone())
assert not third.ok
assert third.reason == "ISO week 41 already has 2 rest days"
assert declare(_MON, now=_EVE).ok # the next week has its own two
def test_an_unreadable_file_refuses(self, caplog: pytest.LogCaptureFixture) -> None:
_rest_day.REST_DAY_FILE.write_text("{not json")
with caplog.at_level(logging.WARNING):
result = declare(_SAT, now=_EVE)
assert not result.ok
assert result.reason == "rest-day file or signing key unreadable"
assert "unreadable" in caplog.text
def test_a_failed_write_says_so(self, caplog: pytest.LogCaptureFixture) -> None:
with (
patch.object(
type(_rest_day.REST_DAY_FILE), "write_text", side_effect=OSError("ro")
),
caplog.at_level(logging.WARNING),
):
result = declare(_SAT, now=_EVE)
assert not result.ok
assert "could not write" in result.reason
class TestReading:
def test_no_key_counts_nothing(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING):
assert rest_day_declarations() == {}
assert "signing key" in caplog.text
with signing_key(tmp_path, b" "):
assert rest_day_declarations() == {}
@pytest.mark.usefixtures("key")
def test_a_hand_edited_row_is_ignored(
self, caplog: pytest.LogCaptureFixture
) -> None:
declare(_SAT, now=_EVE)
edited = rows()
edited[0]["day"] = "2026-10-14"
write(edited)
with caplog.at_level(logging.WARNING):
assert rest_day_declarations() == {}
assert "fails its signature" in caplog.text
@pytest.mark.usefixtures("key")
def test_a_signed_same_day_row_is_not_ahead(self) -> None:
"""A row from any other route still has to be declared ahead."""
body = {
"kind": "rest_day",
"day": "2026-10-10",
"declared_on": "2026-10-10",
"declared_at": 1.0,
}
write([{**body, "hmac": earned_time.entry_signature(body, KEY)}])
assert rest_day_declarations() == {}
@pytest.mark.usefixtures("key")
def test_malformed_and_foreign_rows_are_ignored(
self, caplog: pytest.LogCaptureFixture
) -> None:
body = {
"kind": "rest_day",
"day": "soon",
"declared_on": "2026-10-09",
"declared_at": 1.0,
}
write(
[
"x",
{"kind": "other"},
{**body, "hmac": earned_time.entry_signature(body, KEY)},
]
)
with caplog.at_level(logging.WARNING):
assert rest_day_declarations() == {}
assert "malformed" in caplog.text
@pytest.mark.usefixtures("key")
def test_a_file_without_entries_counts_nothing(
self, caplog: pytest.LogCaptureFixture
) -> None:
_rest_day.REST_DAY_FILE.write_text("[]")
with caplog.at_level(logging.WARNING):
assert rest_day_declarations() == {}
assert "no entries array" in caplog.text
@pytest.mark.usefixtures("key")
def test_the_cap_holds_on_read(self) -> None:
"""Three signed rows in a week: the two declared first count."""
def row(day: str, at: float) -> dict[str, object]:
body = {
"kind": "rest_day",
"day": day,
"declared_on": "2026-10-01",
"declared_at": at,
}
return {**body, "hmac": earned_time.entry_signature(body, KEY)}
write([row("2026-10-07", 3.0), row("2026-10-05", 1.0), row("2026-10-06", 2.0)])
assert set(rest_day_declarations()) == {date(2026, 10, 5), date(2026, 10, 6)}
@pytest.mark.usefixtures("key")
class TestCli:
def _run(self, *argv: str, offset: float | None = 0.0) -> int:
with (
patch("screen_locker._rest_day_cli._query_ntp_offset", return_value=offset),
patch("screen_locker._rest_day_cli.today_str", return_value="2026-10-09"),
patch("screen_locker._rest_day.datetime") as clock,
):
clock.now.return_value = _EVE
return run_declare_rest_day(list(argv))
def test_declare_tomorrow(self, capsys: pytest.CaptureFixture[str]) -> None:
assert self._run("tomorrow") == 0
assert "2026-10-10 is a rest day" in capsys.readouterr().out
def test_a_date_and_then_the_list(self, capsys: pytest.CaptureFixture[str]) -> None:
assert self._run("2026-10-11") == 0
assert self._run("--list") == 0
assert capsys.readouterr().out.splitlines()[-1] == "2026-10-11"
def test_an_empty_list(self, capsys: pytest.CaptureFixture[str]) -> None:
assert self._run() == 0
assert capsys.readouterr().out.strip() == "no rest days declared"
def test_a_bad_date(self, capsys: pytest.CaptureFixture[str]) -> None:
assert self._run("someday") == 2
assert capsys.readouterr().out.startswith("error:")
def test_no_ntp_refuses(self, capsys: pytest.CaptureFixture[str]) -> None:
assert self._run("tomorrow", offset=None) == 1
assert "cannot be confirmed" in capsys.readouterr().out
def test_a_skewed_clock_refuses(self, capsys: pytest.CaptureFixture[str]) -> None:
assert self._run("tomorrow", offset=86400.0) == 1
assert "off by 86400s" in capsys.readouterr().out
def test_today_is_refused(self, capsys: pytest.CaptureFixture[str]) -> None:
assert self._run("2026-10-09") == 1
assert "refused: 2026-10-09 is not in the future" in capsys.readouterr().out
def test_the_key_binding_is_the_one_earned_reads() -> None:
"""The suite redirects ``_earned.HMAC_KEY_FILE``; rest days must follow it."""
assert not _earned.HMAC_KEY_FILE.exists()

View File

@ -0,0 +1,177 @@
"""A day's RunnerUp walks are summed into one workout; never one file at a time."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import json
import logging
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from screen_locker._runnerup_verification import RunnerUpVerificationMixin
if TYPE_CHECKING:
from pathlib import Path
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
_DAY = "2026-10-10"
def tcx(sport: str, minutes: int, start: str, speed: float = 1.3) -> str:
"""A RunnerUp-shaped export: trackpoints every 5 s at ``speed`` m/s."""
begin = datetime.fromisoformat(f"{_DAY}T{start}:00+00:00")
points = "".join(
f"<Trackpoint><Time>{(begin + timedelta(seconds=s)).isoformat()}</Time>"
f"<DistanceMeters>{s * speed:.1f}</DistanceMeters></Trackpoint>"
for s in range(0, minutes * 60 + 1, 5)
)
return (
f'<TrainingCenterDatabase xmlns="{_NS}"><Activities>'
f'<Activity Sport="{sport}"><Id>{_DAY}T{start}</Id><Lap>'
f"<TotalTimeSeconds>{minutes * 60}</TotalTimeSeconds>"
f"<DistanceMeters>{minutes * 60 * speed}</DistanceMeters>"
f"<Track>{points}</Track></Lap></Activity></Activities>"
"</TrainingCenterDatabase>"
)
class Verifier(RunnerUpVerificationMixin):
"""The real mixin, reading one temporary drop directory and no phone."""
def __init__(self, folder: Path) -> None:
self.folder = folder
self.adb = MagicMock(return_value=(False, ""))
def _has_adb_device(self) -> bool:
return False
def _run_adb(self, args: list[str]) -> tuple[bool, str]:
return self.adb(args)
def _find_runnerup_exports_for_date(self, date_str: str) -> list[str]:
return sorted(str(p) for p in self.folder.glob(f"*{date_str}*.tcx"))
def drop(folder: Path, name: str, body: str) -> None:
(folder / f"RunnerUp_Pixel_6a_{_DAY}-{name}.tcx").write_text(body)
@pytest.fixture
def verifier(tmp_path: Path) -> Verifier:
return Verifier(tmp_path)
class TestParse:
def test_a_walk_is_flagged_by_its_file_name(self, verifier: Verifier) -> None:
drop(verifier.folder, "08-00-00_Walking", tcx("Other", 10, "08:00"))
(path,) = verifier._find_runnerup_exports_for_date(_DAY)
data = verifier._pull_and_parse_tcx(path)
assert data is not None
assert data["walking"] is True
assert data["moving_seconds"] == pytest.approx(600)
def test_a_pulled_copy_keeps_the_phone_name(self, verifier: Verifier) -> None:
"""adb pulls into ``activity.tcx``; the remote name still decides."""
body = tcx("Other", 10, "08:00")
def pull(args: list[str]) -> tuple[bool, str]:
from pathlib import Path
Path(args[2]).write_text(body)
return True, ""
verifier.adb.side_effect = pull
data = verifier._pull_and_parse_tcx("/sdcard/RunnerUp_x_Walking.tcx")
assert data is not None
assert data["walking"] is True
def test_a_failed_pull_is_none(self, verifier: Verifier) -> None:
assert verifier._pull_and_parse_tcx("/sdcard/RunnerUp_x_Walking.tcx") is None
class TestVerifyToday:
def _verify(self, verifier: Verifier) -> tuple[str, str] | None:
with patch("screen_locker._runnerup_verification.today_str", return_value=_DAY):
return verifier._verify_runnerup_via_files()
def test_walks_summing_to_the_bar_verify(self, verifier: Verifier) -> None:
drop(verifier.folder, "08-00-00_Walking", tcx("Other", 25, "08:00"))
drop(verifier.folder, "17-00-00_Walking", tcx("Other", 20, "17:00"))
result = self._verify(verifier)
assert result is not None
assert result[0] == "verified"
assert result[1].startswith("Walking: 2 walk(s)")
def test_one_long_elapsed_walk_is_judged_by_moving_time(
self, verifier: Verifier
) -> None:
"""60 min on the clock, standing still: not a workout."""
drop(verifier.folder, "08-00-00_Walking", tcx("Other", 60, "08:00", 0.1))
assert self._verify(verifier) == (
"too_short",
"Walking: 1 walk(s), 0.4 km, 0 min moving -- need 40+ min moving",
)
def test_a_run_still_verifies_beside_short_walks(self, verifier: Verifier) -> None:
drop(verifier.folder, "07-00-00_Walking", tcx("Other", 10, "07:00"))
drop(verifier.folder, "18-00-00_Running", tcx("Running", 45, "18:00", 2.5))
result = self._verify(verifier)
assert result is not None
assert result[0] == "verified"
assert result[1].startswith("Running")
def test_a_short_run_outranks_the_walk_shortfall(self, verifier: Verifier) -> None:
drop(verifier.folder, "07-00-00_Walking", tcx("Other", 10, "07:00"))
drop(verifier.folder, "18-00-00_Running", tcx("Running", 5, "18:00", 2.5))
result = self._verify(verifier)
assert result is not None
assert result[0] == "too_short"
assert result[1].startswith("Run was")
class TestBackfill:
def test_walks_are_logged_as_one_workout(
self, verifier: Verifier, tmp_path: Path
) -> None:
drop(verifier.folder, "08-00-00_Walking", tcx("Other", 25, "08:00"))
drop(verifier.folder, "17-00-00_Walking", tcx("Other", 20, "17:00"))
log = tmp_path / "log.json"
ingested = MagicMock()
assert verifier._try_fill_runnerup_for_date(_DAY, log, ingested) is True
(entry,) = json.loads(log.read_text())[_DAY]
data = entry["workout_data"]
assert data["type"] == "runnerup_verified"
assert data["activity"] == "walking"
assert data["duration_minutes"] == pytest.approx(45)
assert (
data["completed_at"]
== datetime(2026, 10, 10, 17, 20, tzinfo=UTC).timestamp()
)
ingested.assert_called_once()
# The same day again is the same slot: nothing new.
assert verifier._try_fill_runnerup_for_date(_DAY, log, ingested) is False
def test_walks_short_of_the_bar_log_nothing(
self,
verifier: Verifier,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
drop(verifier.folder, "08-00-00_Walking", tcx("Other", 30, "08:00"))
log = tmp_path / "log.json"
with caplog.at_level(logging.WARNING):
assert verifier._try_fill_runnerup_for_date(_DAY, log) is False
assert "do not count yet" in caplog.text
assert not log.exists()
def test_a_run_records_its_end_time(
self, verifier: Verifier, tmp_path: Path
) -> None:
drop(verifier.folder, "18-00-00_Running", tcx("Running", 45, "18:00", 2.5))
log = tmp_path / "log.json"
assert verifier._try_fill_runnerup_for_date(_DAY, log) is True
(entry,) = json.loads(log.read_text())[_DAY]
end = datetime(2026, 10, 10, 18, 45, tzinfo=UTC).timestamp()
assert entry["workout_data"]["completed_at"] == end

View File

@ -186,5 +186,17 @@ class TestProjectionFollowsTheCut:
lowered = 19 * 60 - sum(
e.shutdown_minutes for e in earned_time.EARNERS if e.penalty_from == tuesday
)
assert [d.minutes for d in shutdown.rest_of_week] == [19 * 60] + [lowered] * 6
assert [d.minutes for d in shutdown.next_week_preview] == [lowered] * 7
def base(day: date) -> int:
# From the sleep ladder (Sat 2026-10-10) the registry prices the
# day itself; before it, the cut from Tuesday holds.
return earned_time.base_for(day, earned_time.EARNERS).shutdown_minutes
week = [date(2026, 10, 5 + i) for i in range(7)]
assert [base(d) for d in week[1:5]] == [lowered] * 4
assert [d.minutes for d in shutdown.rest_of_week] == [19 * 60] + [
base(d) for d in week[1:]
]
assert [d.minutes for d in shutdown.next_week_preview] == [
base(date(2026, 10, 12 + i)) for i in range(7)
]

View File

@ -16,7 +16,6 @@ from unittest.mock import MagicMock, patch
import earned_time
import pytest
from screen_locker import _shutdown_base
from screen_locker._day import today_str
from screen_locker._earned import flat_earners, hhmm
from screen_locker._shutdown_base import (
@ -65,15 +64,16 @@ class TestBaseHour:
assert base_minutes(date(2026, 10, 1)) == 19 * 60
def test_long_after_the_cut_stays_lowered(self) -> None:
"""No drift back to 20:00; every later earner's cut holds too."""
cut = earned_time.READING.penalty_from
assert cut is not None
later_cuts = sum(
e.shutdown_minutes
for e in earned_time.EARNERS
if e.penalty_from is not None and e.penalty_from > cut
)
assert base_minutes(date(2030, 1, 1)) == 19 * 60 - later_cuts
"""No drift back to 20:00: the registry's base for the day holds.
From earned_time's sleep ladder (2026-10-10) the floor is the ceiling
minus every earner's first unit, not 20:00 minus the cuts, so the day
is priced by the registry and only the "stays lowered" part is ours.
"""
later = date(2030, 1, 1)
expected = earned_time.base_for(later, earned_time.EARNERS).shutdown_minutes
assert base_minutes(later) == expected
assert expected <= 19 * 60
def test_default_is_the_local_today(self) -> None:
assert base_minutes() == base_minutes(datetime.now().astimezone().date())
@ -120,7 +120,7 @@ class TestResetIncludesReading:
answers[0]["leetcode"] = True
answers[0]["reading"] = True
mixin = _mixin()
with patch.object(_shutdown_base, "today_credit_count", return_value=9):
with patch("screen_locker._shutdown_target.day_credit_count", return_value=9):
reset_to_base_if_new_day(
tmp_path / "state.json", mixin, log_file=tmp_path / "log.json"
)

View File

@ -0,0 +1,77 @@
"""Tests for ``_shutdown_target``: the one formula behind reset and preview."""
from __future__ import annotations
from datetime import date, datetime, time
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
from screen_locker import _grace_floor
from screen_locker._shutdown_target import (
DayInputs,
day_credit_count,
derive,
gather,
workout_units,
)
if TYPE_CHECKING:
from pathlib import Path
_DAY = date(2026, 10, 10)
_ISO = _DAY.isoformat()
_FLAT = {"leetcode": False, "reading": False, "anki": False, "automation": False}
def at(hhmm: str) -> float:
hours, minutes = map(int, hhmm.split(":"))
return datetime.combine(_DAY, time(hours, minutes)).astimezone().timestamp()
def log_with(tmp_path: Path, entries: list[dict[str, object]]) -> Path:
log = tmp_path / "log.json"
log.write_text(json.dumps({_ISO: entries}))
return log
class TestTarget:
def test_a_rest_day_stands_in_for_the_first_workout_only(self) -> None:
assert workout_units(0, rest_day=True) == 1
assert workout_units(2, rest_day=True) == 2
assert workout_units(0, rest_day=False) == 0
def test_the_floor_lifts_and_the_lift_is_reported(self) -> None:
target = derive(
DayInputs(_DAY, {**_FLAT, "anki": True}, first_done=at("18:55"))
)
assert target.minutes == max(target.earned, 19 * 60 + 55)
assert target.lift == target.minutes - target.earned
def test_a_sick_day_drops_the_floor(self) -> None:
target = derive(DayInputs(_DAY, _FLAT, first_done=at("18:55"), sick_day=True))
assert target.grace is None
assert target.minutes == target.earned
def test_no_log_counts_no_workouts(self) -> None:
assert day_credit_count(None, _DAY) == 0
def test_gather_reads_every_real_source(self, tmp_path: Path) -> None:
log = log_with(
tmp_path,
[
{
"workout_data": {"type": "manual_workout", "end_time": "18:00"},
"timestamp": "2026-10-10T20:00:00+00:00",
}
],
)
with (
patch("screen_locker._shutdown_target.flat_answers", return_value=_FLAT),
patch.object(_grace_floor, "first_credit_time", return_value=None),
):
target = gather(_DAY, log)
assert target.resolution.term("workout").answer == 1
assert target.first_done_at == at("18:00")
assert target.grace == 19 * 60
assert not target.rest_day

View File

@ -0,0 +1,134 @@
"""Tests for ``_walk_tcx``: which exports are walks, and their moving time."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import logging
from typing import TYPE_CHECKING, Any
import xml.etree.ElementTree as ET
import pytest
from screen_locker._walk_tcx import (
is_walking_export,
track_summary,
walk_day,
walk_shortfall,
)
if TYPE_CHECKING:
from collections.abc import Sequence
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
_START = datetime(2026, 10, 10, 8, 0, tzinfo=UTC)
def activity(points: Sequence[tuple[float, str | None, str | None]]) -> ET.Element:
"""An Activity of ``(seconds after start, metres, raw time override)``.
A raw time of ``"-"`` leaves the point without a time.
"""
root = ET.Element(f"{{{_NS}}}Activity", Sport="Other")
ET.SubElement(root, f"{{{_NS}}}Id").text = "A1"
track = ET.SubElement(ET.SubElement(root, f"{{{_NS}}}Lap"), f"{{{_NS}}}Track")
for sec, metres, raw in points:
point = ET.SubElement(track, f"{{{_NS}}}Trackpoint")
if raw != "-":
stamp = raw or (_START + timedelta(seconds=sec)).isoformat()
ET.SubElement(point, f"{{{_NS}}}Time").text = stamp
if metres:
ET.SubElement(point, f"{{{_NS}}}DistanceMeters").text = metres
return root
def walk(seconds: int, speed: float = 1.3) -> ET.Element:
"""A steady walk sampled every 5 s."""
return activity([(s, f"{s * speed:.1f}", None) for s in range(0, seconds + 1, 5)])
class TestIsWalkingExport:
def test_the_file_name_names_the_sport(self) -> None:
"""RunnerUp's default export writes Sport="Other"; the name says Walking."""
assert is_walking_export("RunnerUp_2026-10-10-08-00-00_Walking.tcx", "Other")
def test_the_strava_variant_tags_the_activity(self) -> None:
assert is_walking_export("RunnerUp_2026-10-10-08-00-00.tcx", "Walking")
def test_other_alone_is_not_a_walk(self) -> None:
assert not is_walking_export("RunnerUp_2026-10-10_Running.tcx", "Other")
class TestTrackSummary:
def test_walking_speed_is_moving(self) -> None:
summary = track_summary(walk(600))
assert summary["moving_seconds"] == pytest.approx(600)
assert summary["activity_id"] == "A1"
assert summary["ended_at"] == (_START + timedelta(seconds=600)).timestamp()
def test_standing_and_vehicles_are_not_moving(self) -> None:
assert track_summary(walk(600, speed=0.1))["moving_seconds"] == 0
assert track_summary(walk(600, speed=8.0))["moving_seconds"] == 0
def test_a_gps_dropout_earns_nothing(self) -> None:
"""Two points 100 s apart at walking pace: motion unseen, not counted."""
summary = track_summary(activity([(0, "0.1", None), (100, "130", None)]))
assert summary["moving_seconds"] == 0
def test_unusable_points_are_skipped(
self, caplog: pytest.LogCaptureFixture
) -> None:
points = [
(0, "0.1", None),
(5, None, None), # no distance
(6, "8", "-"), # no time
(7, "9", "not-a-time"),
(10, "13", None),
]
with caplog.at_level(logging.WARNING):
summary = track_summary(activity(points))
assert summary["moving_seconds"] == pytest.approx(10)
assert "unparsable time" in caplog.text
def test_no_points_has_no_end(self) -> None:
summary = track_summary(activity([]))
assert summary == {"activity_id": "A1", "moving_seconds": 0.0, "ended_at": None}
def leg(
ident: str, minutes: float, ended: float, metres: float = 2000
) -> dict[str, Any]:
return {
"activity_id": ident,
"moving_seconds": minutes * 60,
"ended_at": ended,
"distance_m": metres,
}
class TestWalkDay:
def test_legs_sum_and_complete_when_the_bar_is_crossed(self) -> None:
day = walk_day([leg("b", 20, 200.0), leg("a", 25, 100.0), leg("c", 5, 300.0)])
assert day.legs == 3
assert day.moving_minutes == pytest.approx(50)
assert day.distance_km == pytest.approx(6)
assert day.completed_at == 200.0 # a (25) + b (20) crosses 40 at b's end
assert day.qualifies
assert day.message() == "Walking: 3 walk(s), 6.0 km, 50 min moving"
def test_the_same_walk_from_two_sources_counts_once(self) -> None:
copy = leg("same", 30, 100.0)
day = walk_day([copy, dict(copy)])
assert day.legs == 1
assert not day.qualifies
assert day.completed_at is None
def test_a_walk_without_id_or_end_still_counts(self) -> None:
anonymous = {"moving_seconds": 45 * 60}
day = walk_day([anonymous])
assert day.legs == 1
assert day.qualifies
assert day.completed_at is None
def test_shortfall_says_what_is_missing(self) -> None:
text = walk_shortfall(walk_day([leg("a", 30, 1.0)]))
assert text.endswith("need 40+ min moving")

View File

@ -0,0 +1,197 @@
"""Tests for ``_workout_ledger`` and its backfill CLI: one signed row per credit."""
from __future__ import annotations
from datetime import date, datetime, time
import json
import logging
from typing import TYPE_CHECKING, Any
import earned_time
import pytest
from screen_locker import _workout_ledger
from screen_locker._log_mixin import write_signed_entry
from screen_locker._workout_ledger import (
append_rows,
credit_row,
entry_done_at,
existing_rows,
ledger_path,
new_rows,
rest_row,
)
from screen_locker.tests._earned_fixtures import KEY, signing_key
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_DAY = date(2026, 10, 4)
_ISO = _DAY.isoformat()
def local(hhmm: str, day: date = _DAY) -> float:
hours, minutes = map(int, hhmm.split(":"))
return datetime.combine(day, time(hours, minutes)).astimezone().timestamp()
@pytest.fixture
def key(tmp_path: Path) -> Iterator[Path]:
with signing_key(tmp_path) as path:
yield path
def entry(
data: dict[str, Any], stamp: str = "2026-10-04T20:00:00+00:00", **extra: Any
) -> dict[str, Any]:
return {"timestamp": stamp, "workout_data": data, **extra}
def ledger() -> list[dict[str, Any]]:
return json.loads(ledger_path().read_text())["entries"]
class TestEntryDoneAt:
def test_the_tcx_end_wins(self) -> None:
done = entry({"type": "runnerup_verified", "completed_at": 123.5})
assert entry_done_at(done, _DAY) == 123.5
def test_a_numeric_string_and_iso_are_read(self) -> None:
assert entry_done_at(entry({"completed_at": "77.5"}), _DAY) == 77.5
iso = entry({"completed_at": "2026-10-04T10:00:00+00:00"})
assert (
entry_done_at(iso, _DAY)
== datetime.fromisoformat("2026-10-04T10:00:00+00:00").timestamp()
)
def test_a_manual_end_time_is_on_its_day(self) -> None:
manual = entry({"type": "manual_workout", "end_time": "18:40"})
assert entry_done_at(manual, _DAY) == local("18:40")
def test_a_pc_session_is_its_start_plus_duration(self) -> None:
session = entry(
{
"type": "pc_workout_verified",
"sync_record_id": "2026-10-04T15:42:00",
"duration_minutes": "90.0",
}
)
assert entry_done_at(session, _DAY) == local("17:12")
def test_a_session_without_a_duration_falls_back_to_logging(
self, caplog: pytest.LogCaptureFixture
) -> None:
session = entry(
{"type": "pc_workout_verified", "sync_record_id": "2026-10-04T15:42:00"}
)
with caplog.at_level(logging.WARNING):
done = entry_done_at(session, _DAY)
assert done == datetime.fromisoformat("2026-10-04T20:00:00+00:00").timestamp()
assert "no usable duration" in caplog.text
def test_nothing_usable_is_none(self, caplog: pytest.LogCaptureFixture) -> None:
bare = {"workout_data": "?", "timestamp": "never"}
with caplog.at_level(logging.WARNING):
assert entry_done_at(bare, _DAY) is None
assert "Unusable workout time" in caplog.text
class TestCreditRow:
def test_each_source_is_named(self) -> None:
# No workout_id on the entry: the credit slot names the row.
cases = {
"runnerup_verified": ("runnerup_tcx", f"runnerup_verified:{_ISO}#0"),
"phone_verified": ("stronglifts", f"stronglifts:{_ISO}"),
"manual_workout": ("manual", f"manual_workout:{_ISO}#0"),
}
for wtype, (source, entry_id) in cases.items():
row = credit_row(_ISO, [entry({"type": wtype})], 0)
assert row is not None
assert row["detail"]["source"] == source
assert row["entry_id"] == entry_id
def test_a_non_credit_has_no_row(self) -> None:
assert credit_row(_ISO, [entry({"type": "relaxed_day_skip"})], 0) is None
def test_both_stronglifts_paths_share_one_row_and_the_real_end(self) -> None:
phone = entry({"type": "phone_verified"}, workout_id="phone_verified:x")
pc = entry(
{
"type": "pc_workout_verified",
"sync_record_id": "2026-10-04T15:42:00",
"duration_minutes": "90",
}
)
first = credit_row(_ISO, [phone, pc], 0)
second = credit_row(_ISO, [phone, pc], 1)
assert first == second
assert first is not None
assert first["entry_id"] == "phone_verified:x"
assert float(first["detail"]["completed_at"]) == local("17:12")
def test_a_credit_with_no_time_writes_no_row(
self, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING):
row = credit_row(_ISO, [{"workout_data": {"type": "phone_verified"}}], 0)
assert row is None
assert "no usable time" in caplog.text
def test_a_rest_row_is_stamped_at_its_midnight(self) -> None:
row = rest_row(_DAY, 5.0)
assert row["detail"] == {
"completed_at": str(local("00:00")),
"declared_at": "5.0",
"source": "rest_day",
}
class TestAppend:
def test_rows_are_signed_once(self, key: Path) -> None:
del key
row = credit_row(_ISO, [entry({"type": "phone_verified"})], 0)
assert row is not None
assert append_rows([row, dict(row)]) == 1
assert append_rows([dict(row)]) == 0
(stored,) = ledger()
assert earned_time.verified(stored, KEY)
def test_no_key_writes_nothing(self, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
assert append_rows([rest_row(_DAY, 1.0)]) == 0
assert "NOT updated" in caplog.text
assert not ledger_path().exists()
@pytest.mark.usefixtures("key")
def test_a_malformed_ledger_is_left_alone(
self, caplog: pytest.LogCaptureFixture
) -> None:
ledger_path().parent.mkdir(parents=True)
ledger_path().write_text("[]")
with caplog.at_level(logging.WARNING):
assert append_rows([rest_row(_DAY, 1.0)]) == 0
assert existing_rows(ledger_path()) is None
assert ledger_path().read_text() == "[]"
def test_new_rows_dedups_within_and_against_the_ledger(self) -> None:
a, b = rest_row(_DAY, 1.0), rest_row(date(2026, 10, 5), 1.0)
assert new_rows([a, b, dict(b)], [a]) == [b]
@pytest.mark.usefixtures("key")
def test_every_log_credit_reaches_the_ledger(self, tmp_path: Path) -> None:
"""The log write chokepoint is the ledger's only writer for credits."""
log = tmp_path / "log.json"
write_signed_entry(
log,
_ISO,
{"type": "manual_workout", "start_time": "17:00", "end_time": "18:00"},
)
write_signed_entry(log, _ISO, {"type": "relaxed_day_skip"})
(row,) = ledger()
assert row["detail"]["source"] == "manual"
assert float(row["detail"]["completed_at"]) == local("18:00")
def test_the_ledger_lives_under_the_redirected_home() -> None:
assert ledger_path().is_relative_to(_workout_ledger._earned.LEDGER_HOME)

View File

@ -0,0 +1,95 @@
"""Tests for ``--backfill-workout-ledger``: idempotent, per source, range-aware."""
from __future__ import annotations
from datetime import date, datetime
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import pytest
from screen_locker._rest_day import declare
from screen_locker._workout_ledger import ledger_path
from screen_locker._workout_ledger_cli import backfill_rows, run_backfill
from screen_locker.tests._earned_fixtures import signing_key
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_DAY = date(2026, 10, 4)
_ISO = _DAY.isoformat()
@pytest.fixture
def key(tmp_path: Path) -> Iterator[Path]:
with signing_key(tmp_path) as path:
yield path
def entry(data: dict[str, Any]) -> dict[str, Any]:
return {"timestamp": "2026-10-04T20:00:00+00:00", "workout_data": data}
def ledger() -> list[dict[str, Any]]:
return json.loads(ledger_path().read_text())["entries"]
@pytest.mark.usefixtures("key")
class TestBackfill:
def _log(self, tmp_path: Path) -> Path:
log = tmp_path / "log.json"
log.write_text(
json.dumps(
{
_ISO: [
entry({"type": "phone_verified"}),
entry({"type": "relaxed_day_skip"}),
],
"2026-10-05": [
entry({"type": "runnerup_verified", "completed_at": 9.0})
],
}
)
)
return log
def test_rows_for_a_range_and_its_rest_days(self, tmp_path: Path) -> None:
with patch("screen_locker._rest_day.datetime") as clock:
clock.now.return_value = datetime(2026, 10, 1).astimezone()
assert declare(date(2026, 10, 6)).ok
ledger_path().unlink() # the declaration mirrored it; backfill must too
rows = backfill_rows(self._log(tmp_path), _DAY, date(2026, 10, 6))
assert [r["detail"]["source"] for r in rows] == [
"stronglifts",
"runnerup_tcx",
"rest_day",
]
def test_dry_run_prints_and_writes_nothing(
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
argv = ["--dry-run", "--from", _ISO, "--to", "2026-10-05"]
assert run_backfill(self._log(tmp_path), argv) == 0
out = capsys.readouterr().out
assert "(missing)" in out
assert "per source: {'runnerup_tcx': 1, 'stronglifts': 1}" in out
assert not ledger_path().exists()
def test_a_real_run_is_idempotent(
self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
log = self._log(tmp_path)
assert run_backfill(log, ["--from", _ISO, "--to", "2026-10-05"]) == 0
assert run_backfill(log, ["--from", _ISO, "--to", "2026-10-05"]) == 0
out = capsys.readouterr().out.splitlines()
assert out[0].startswith("2 new row(s)")
assert out[1].startswith("0 new row(s)")
assert run_backfill(log, ["--dry-run"]) == 0
assert "(present)" in capsys.readouterr().out
def test_default_is_today(self, tmp_path: Path) -> None:
with patch("screen_locker._workout_ledger_cli.today_str", return_value=_ISO):
assert run_backfill(self._log(tmp_path), []) == 0
assert len(ledger()) == 1

105
scripts/_preview_sim.py Normal file
View File

@ -0,0 +1,105 @@
"""Simulation doubles for ``preview_shutdown.py``: real code, temporary state.
Split out to keep the script under the 250-line cap. Every class here runs
the production methods it inherits; only the I/O edges (the drop directory,
adb, the sudo-written config, the sick history) point at a temporary place.
"""
from __future__ import annotations
from datetime import date, datetime, time
import json
from typing import TYPE_CHECKING
import earned_time
from screen_locker import _grace_floor, _sick_tracker
from screen_locker._earned import first_minutes
from screen_locker._runnerup_verification import RunnerUpVerificationMixin
from screen_locker._shutdown import ShutdownMixin
if TYPE_CHECKING:
from pathlib import Path
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
HELPER_CEILING = 23 * 60 # adjust_shutdown_schedule.sh's RESTORE_CEILING
def stamp(day: date, hhmm_text: str) -> float:
"""Unix time of ``HH:MM`` local on ``day``."""
hours, _, minutes = hhmm_text.partition(":")
moment = datetime.combine(day, time(int(hours), int(minutes))).astimezone()
return moment.timestamp()
def _iso(stamp: float) -> str:
return datetime.fromtimestamp(stamp).astimezone().isoformat()
def write_walk(folder: Path, day: date, start: str, minutes: int) -> None:
"""A RunnerUp-style walk export: Sport="Other", the sport in the name."""
begin = stamp(day, start)
points = "".join(
f"<Trackpoint><Time>{_iso(begin + s)}</Time>"
f"<DistanceMeters>{s * 1.3:.1f}</DistanceMeters></Trackpoint>"
for s in range(0, minutes * 60 + 1, 5)
)
stem = f"RunnerUp_Pixel_6a_{day}-{start.replace(':', '-')}-00_Walking.tcx"
(folder / stem).write_text(
f'<TrainingCenterDatabase xmlns="{_NS}"><Activities><Activity Sport="Other">'
f"<Id>{day}T{start}:00Z</Id><Lap><TotalTimeSeconds>{minutes * 60}"
f"</TotalTimeSeconds><DistanceMeters>{minutes * 78}</DistanceMeters>"
f"<Track>{points}</Track></Lap></Activity></Activities></TrainingCenterDatabase>",
encoding="utf-8",
)
class SimRunnerUp(RunnerUpVerificationMixin):
"""The real backfill, reading only a temporary drop directory."""
def __init__(self, folder: Path) -> None:
self.folder = folder
def _has_adb_device(self) -> bool:
return False
def fill(self, day: str, log: Path) -> bool:
"""The real RunnerUp backfill for ``day`` into ``log``."""
return self._try_fill_runnerup_for_date(day, log)
def _find_runnerup_exports_for_date(self, date_str: str) -> list[str]:
return sorted(str(p) for p in self.folder.glob(f"*{date_str}*.tcx"))
class SimShutdown(ShutdownMixin):
"""The real add/absorb paths against an in-memory config."""
def __init__(self, minutes: int) -> None:
self.config = (minutes, minutes, 5 * 60)
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
return self.config
def _write_shutdown_config(
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = False
) -> bool:
del restore
clamp = min(HELPER_CEILING, mon_wed), min(HELPER_CEILING, thu_sun)
self.config = (*clamp, morning_end)
return True
def credit(self, name: str, day: date, first_done: float) -> None:
"""One live pass: the earner's add (lift-absorbing), then the floor."""
if name == earned_time.WORKOUT.name:
self._adjust_shutdown_time_later()
else:
self._adjust_shutdown_time_by(first_minutes(earned_time.earner(name), day))
self._apply_grace_floor(None, first_done=first_done)
def sick_history(day: date, tmp: Path, *, sick_day: bool) -> bool:
"""A temp sick history naming ``day``, read back through the real check."""
history = tmp / "sick_history.json"
history.write_text(json.dumps({"sick_days": [day.isoformat()] if sick_day else []}))
vars(_sick_tracker)["SICK_HISTORY_FILE"] = history
return _grace_floor.sick_on(day)

187
scripts/preview_shutdown.py Executable file
View File

@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Print the shutdown the locker would derive -- today, or a simulated day.
READ-ONLY on the real system: it never runs the sudo helper, never writes
``/etc``, ``shutdown_base.json``, ``grace_floor.json``, ``rest_days.json``,
``log.json`` or the real workout ledger. Simulations run the real code paths
(TCX parsing + walk summing via the RunnerUp backfill, rest-day signing and
verification, ``_shutdown_target.derive``, the grace floor and its lift
bookkeeping) against a temporary directory, with an in-memory shutdown config.
python -m scripts.preview_shutdown # today, real ledgers
python -m scripts.preview_shutdown --day D --walk 25,20
python -m scripts.preview_shutdown --day D --rest-day
python -m scripts.preview_shutdown --day D --first-done 18:55 --done anki
python -m scripts.preview_shutdown --day D --first-done 18:55 --sick-day
python -m scripts.preview_shutdown --day D --sequence anki@21:00,reading@21:20
Run it with ``PYTHONPATH=~/src/utils/earned_time`` to preview the ladder
(earned_time working tree) instead of the installed version.
"""
from __future__ import annotations
import argparse
from datetime import date, datetime, time, timedelta
from pathlib import Path
import sys
import tempfile
import earned_time
from screen_locker import _earned, _grace_floor, _shutdown
from screen_locker._constants import EXTRA_BENEFITS_FILE, SHUTDOWN_CONFIG_FILE
from screen_locker._day import today_str
from screen_locker._earned import hhmm
from screen_locker._extra_benefits import weekly_shutdown_bonus_hours
from screen_locker._rest_day import declare, is_rest_day
from screen_locker._shutdown import read_shutdown_config
from screen_locker._shutdown_target import (
DayInputs,
Target,
day_credit_count,
derive,
gather,
)
from scripts._preview_sim import (
HELPER_CEILING,
SimRunnerUp,
SimShutdown,
sick_history,
stamp,
write_walk,
)
_LOG = Path(__file__).resolve().parents[1] / "screen_locker" / "log.json"
_SICK_STEP, _SICK_FLOOR = 60, 18 * 60 # _shutdown's sick-day step and floor
def _say(text: str) -> None:
sys.stdout.write(f"{text}\n")
def _report(label: str, target: Target) -> None:
res = target.resolution
terms = ", ".join(
f"{t.earner.name}={t.answer}:+{t.shutdown_minutes}" for t in res.terms
)
first = (
datetime.fromtimestamp(target.first_done_at).astimezone().strftime("%H:%M")
if target.first_done_at
else "-"
)
grace = hhmm(target.grace) if target.grace is not None else "-"
_say(f"[{label}] {res.day} base {hhmm(res.base.shutdown_minutes)} | {terms}")
_say(
f" rest_day={target.rest_day} earned={hhmm(target.earned)} "
f"first_done={first} grace={grace} -> SHUTDOWN {hhmm(target.minutes)}"
)
def _simulate(args: argparse.Namespace, tmp: Path) -> None:
day = date.fromisoformat(args.day)
# Every ledger (the workout one included) resolves under the temp dir.
vars(_earned)["LEDGER_HOME"] = tmp
log = tmp / "log.json"
if args.walk:
drop = tmp / "drop"
drop.mkdir()
for index, minutes in enumerate(int(m) for m in args.walk.split(",")):
write_walk(drop, day, f"{8 + index * 9:02d}:15", minutes)
SimRunnerUp(drop).fill(day.isoformat(), log)
rest_file = tmp / "rest_days.json"
if args.rest_day:
eve = datetime.combine(day - timedelta(days=1), time(12)).astimezone()
_say(f" declare: {declare(day, now=eve, path=rest_file).reason}")
rest = is_rest_day(day, rest_file)
done = {n for n in (args.done or "").split(",") if n}
flat = {e.name: e.name in done for e in _earned.flat_earners()}
first = stamp(day, args.first_done) if args.first_done else None
logged = _grace_floor.workout_done_at(log, day)
times = [t for t in (first, logged) if t is not None]
target = derive(
DayInputs(
day,
flat,
day_credit_count(log, day),
rest_day=rest,
first_done=min(times, default=None),
sick_day=sick_history(day, tmp, sick_day=args.sick_day),
)
)
_report("simulated", target)
if args.sick_day:
# What the lock screen's sick-day choice then does to that (the
# existing _apply_earlier_shutdown rule); no grace floor lifts it back.
after = max(_SICK_FLOOR, target.minutes - _SICK_STEP)
_say(f" sick-day step at the lock screen -> SHUTDOWN {hhmm(after)}")
def _sequence(args: argparse.Namespace, tmp: Path) -> None:
day = date.fromisoformat(args.day)
for module in (_grace_floor, _shutdown):
vars(module)["today_str"] = lambda: args.day
vars(_grace_floor)["GRACE_STATE_FILE"] = tmp / "grace_floor.json"
done = {e.name: False for e in _earned.flat_earners()}
start = derive(DayInputs(day, done))
sim = SimShutdown(start.minutes)
_say(f"[sequence] {day} start {hhmm(start.minutes)} (nothing done)")
workouts, first = 0, None
for step in args.sequence.split(","):
name, _, at = step.partition("@")
first = min(first or stamp(day, at), stamp(day, at))
if name == earned_time.WORKOUT.name:
workouts += 1
else:
done[name] = True
sim.credit(name, day, first)
want = derive(DayInputs(day, done, workouts, first_done=first))
verdict = "" if sim.config[0] == want.minutes else " MISMATCH"
_say(
f" {name}@{at}: config {hhmm(sim.config[0])} "
f"(derived {hhmm(want.minutes)}{verdict})"
)
def main() -> int:
"""Print the requested preview. Returns 0."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--day", default=None, help="simulate this day (YYYY-MM-DD)")
parser.add_argument("--walk", help="moving minutes per walk leg, e.g. 25,20")
parser.add_argument("--rest-day", action="store_true")
parser.add_argument("--sick-day", action="store_true")
parser.add_argument("--first-done", help="HH:MM a ledger earner was first done")
parser.add_argument("--done", help="flat earners done, e.g. anki,leetcode")
parser.add_argument("--sequence", help="name@HH:MM,... live passes in order")
args = parser.parse_args()
ladder = getattr(earned_time, "on_ladder", None)
_say(f"earned_time from {Path(earned_time.__file__).parent}")
if args.day is None:
today = date.fromisoformat(today_str())
on = ladder(today) if ladder else False
_say(f" ladder in force today: {on}")
live = read_shutdown_config(SHUTDOWN_CONFIG_FILE)
_say(
f" live config: {tuple(hhmm(m) for m in live) if live else 'unreadable'}"
)
target = gather(today, _LOG)
_report("today, real ledgers", target)
weekly = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE) * 60
_say(
f" + weekly streak bonus {weekly} min (layered after the reset, "
f"absorbs the grace lift) -> expected config "
f"{hhmm(min(HELPER_CEILING, max(target.earned + weekly, target.minutes)))}"
)
return 0
day = date.fromisoformat(args.day)
_say(f" ladder in force on {day}: {ladder(day) if ladder else False}")
with tempfile.TemporaryDirectory(prefix="preview_shutdown_") as raw:
if args.sequence:
_sequence(args, Path(raw))
else:
_simulate(args, Path(raw))
return 0
if __name__ == "__main__":
sys.exit(main())