mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
154 lines
5.5 KiB
Python
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
|