wake-alarm/wake_alarm/_workday_penalty.py

154 lines
5.5 KiB
Python

"""Tue/Wed/Thu miss -> tomorrow loses its usual leniency.
The no-lock carrot (``_session_policy``) only motivates finishing the
morning when the PC is around to enjoy it -- true on a weekend, false on a
workday, since the user leaves for work regardless of whether the PC is
locked. On Tuesday/Wednesday/Thursday -- the same set screen-locker calls
"relaxed" (``_weekly_check.is_relaxed_day``) and leetcode-guard prices as
cheap (``Pricing.doubled_days``) -- a miss instead costs tomorrow's leniency
in both, so skipping is never free just because nobody is home to notice.
Distinct from ``morning_session.json``: that file always describes *today*
and is overwritten at the date rollover, so it cannot carry a marker forward
to tomorrow. This file's only field a consumer checks is ``penalty_date``:
today == penalty_date means "no leniency today," the same shape as
``morning_session.json``'s own ``exempt_until`` predicate.
An escape (``DOCS-wake-enforcement.md``) for a morning means no penalty is
written for it, and one already issued that day is deleted.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
import json
import logging
import freedays
from gatelock.log_integrity import compute_entry_hmac
from wake_alarm._constants import WORKDAY_PENALTY_FILE
from wake_alarm._events import emit
from wake_alarm._session_policy import NONE_OUTCOME
_logger = logging.getLogger(__name__)
def is_stick_day(day: date) -> bool:
"""Tuesday/Wednesday/Thursday: ``freedays.WORKDAYS``, the shared set."""
return freedays.is_workday(day)
def is_workday_miss(entry: dict[str, object], now: datetime) -> bool:
"""Whether today's session entry is a genuine miss, not just late.
``failed`` is always a miss. ``none`` is only a miss once its own grace
has expired -- before that the morning could still start. A late
``completed`` (the walk happened, just after the carrot's cutoff) is
deliberately not a miss: the goal was waking up, and that happened.
"""
outcome = entry.get("outcome")
if outcome == "failed":
return True
if outcome != NONE_OUTCOME:
return False
until = entry.get("exempt_until")
if not isinstance(until, str):
return False
try:
return now >= datetime.fromisoformat(until)
except ValueError:
return False
def penalty_entry(*, today: date) -> dict[str, object]:
"""The unsigned body: tomorrow loses its leniency."""
return {
"penalty_date": (today + timedelta(days=1)).isoformat(),
"issued_date": today.isoformat(),
}
def write_penalty(entry: dict[str, object]) -> bool:
"""Sign and write ``entry``; False when the disk refused."""
signature = compute_entry_hmac(entry)
if signature is not None:
entry["hmac"] = signature
else:
_logger.warning("HMAC key unavailable — writing unsigned workday penalty")
try:
WORKDAY_PENALTY_FILE.parent.mkdir(parents=True, exist_ok=True)
WORKDAY_PENALTY_FILE.write_text(json.dumps(entry, indent=2))
except OSError as exc:
_logger.warning("Could not write %s: %s", WORKDAY_PENALTY_FILE, exc)
return False
return True
def issued_date() -> str | None:
"""``issued_date`` of the penalty on disk, or None when there is none."""
try:
raw = json.loads(WORKDAY_PENALTY_FILE.read_text())
except OSError, ValueError:
return None
issued = raw.get("issued_date") if isinstance(raw, dict) else None
return issued if isinstance(issued, str) else None
def cancel_for_escapes(escaped_dates: frozenset[str]) -> bool:
"""Delete the penalty when an escape excuses the morning it was issued.
Returns:
True when a penalty was removed.
"""
issued = issued_date()
if issued is None or issued not in escaped_dates:
return False
try:
WORKDAY_PENALTY_FILE.unlink()
except OSError as exc:
_logger.warning("Could not cancel %s: %s", WORKDAY_PENALTY_FILE, exc)
return False
emit("penalty_cancelled", f"escape for {issued}", issued_date=issued)
return True
def maybe_apply(
entry: dict[str, object],
now: datetime,
*,
escaped_dates: frozenset[str] = frozenset(),
) -> bool:
"""Write tomorrow's penalty when today is a missed stick-day; else no-op.
Best-effort: never raises, since the per-refresh caller must not fail on
this. A free day is never penalised even if unsynced state elsewhere
would otherwise have rung the alarm -- the pool is fail-closed the other
direction (a corrupt read is "not free"), which is exactly what is
wanted here too: an unreadable pool means no penalty, never a spurious
one. An escaped morning is never penalised either.
``penalty_written`` is logged once per issued day, not on every
two-minute refresh that rewrites the same entry.
"""
today = now.date()
if today.isoformat() in escaped_dates:
return False
if freedays.is_free_day(today) or not is_stick_day(today):
return False
if not is_workday_miss(entry, now):
return False
previously = issued_date()
entry_body = penalty_entry(today=today)
if not write_penalty(entry_body):
return False
if previously != today.isoformat():
emit(
"penalty_written",
f"penalty for {entry_body['penalty_date']}",
penalty_date=entry_body["penalty_date"],
issued_date=entry_body["issued_date"],
outcome=entry.get("outcome"),
)
return True