screen-locker/screen_locker/_rest_day_cli.py
Krzysztof kuhy Rudnicki 373a075606 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>
2026-10-09 10:43:45 +02:00

82 lines
2.8 KiB
Python

"""``--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