Lose Tue/Wed/Thu leniency when wake-alarm signals a missed prior-day ring
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-09-23 21:23:31 +02:00
parent e86dd57817
commit 584177ec2e
6 changed files with 149 additions and 2 deletions

View File

@ -149,6 +149,15 @@ MORNING_SESSION_FILE = (
Path.home() / ".local" / "state" / "wake_alarm" / "morning_session.json"
)
# A separate file from MORNING_SESSION_FILE: that one always describes
# *today* and is overwritten at the date rollover, so it cannot carry a
# marker forward to tomorrow. wake-alarm writes this one when a Tue/Wed/Thu
# ring goes unanswered; today == penalty_date means today's relaxed-day
# leniency is off. Keep the path in step with wake_alarm/_constants.py.
WORKDAY_PENALTY_FILE = (
Path.home() / ".local" / "state" / "wake_alarm" / "workday_penalty.json"
)
# Directories where RunnerUp writes per-activity TCX exports (File Synchronizer).
# Listed in preference order; both resolve to the same path on most devices.
RUNNERUP_EXPORT_DIRS: tuple[str, ...] = (

View File

@ -17,6 +17,7 @@ import logging
from typing import TYPE_CHECKING, Any
from screen_locker._log_io import load_workout_log
from screen_locker._workday_penalty import workday_penalty_today
if TYPE_CHECKING:
from pathlib import Path
@ -77,14 +78,20 @@ RELAXED_DAY_SKIP_TYPE: str = "relaxed_day_skip"
def is_relaxed_day(*, today: datetime | None = None) -> bool:
"""Return True if today is a relaxed day (Tue, Wed, or Thu).
False when a missed wake-alarm ring on a prior stick-day cost today its
leniency -- see :func:`screen_locker._workday_penalty.workday_penalty_today`.
Args:
today: Override for the current local datetime (for testing).
Returns:
True when the current weekday is Tuesday, Wednesday, or Thursday.
True when the current weekday is Tuesday, Wednesday, or Thursday and
no penalty is in force.
"""
dt = today if today is not None else datetime.now(tz=UTC).astimezone()
return dt.weekday() in _RELAXED_WEEKDAYS
if dt.weekday() not in _RELAXED_WEEKDAYS:
return False
return not workday_penalty_today(dt)
def credit_key(

View File

@ -0,0 +1,49 @@
"""Read the workday-stick penalty wake-alarm signed for today.
A missed Tuesday/Wednesday/Thursday wake-alarm ring costs tomorrow's
relaxed-day leniency (:func:`screen_locker._weekly_check.is_relaxed_day`)
instead of just resetting to normal. This module never re-derives that
policy: the file carries a signed ``penalty_date``, and the only decision
here is *HMAC ok, dated today*. Anything else -- missing file, stale date,
bad signature -- is "no penalty", i.e. today's ordinary relaxed-day rule.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
from gatelock.log_integrity import verify_entry_hmac
from screen_locker._constants import WORKDAY_PENALTY_FILE
from screen_locker._day import today_str
if TYPE_CHECKING:
from datetime import datetime
_logger = logging.getLogger(__name__)
def _read_verified() -> dict[str, object] | None:
"""The file's entry when it exists, parses and verifies; else None."""
if not WORKDAY_PENALTY_FILE.exists():
_logger.debug("No workday penalty file at %s", WORKDAY_PENALTY_FILE)
return None
try:
entry = json.loads(WORKDAY_PENALTY_FILE.read_text())
except (OSError, json.JSONDecodeError) as exc:
_logger.warning("Cannot read %s: %s", WORKDAY_PENALTY_FILE, exc)
return None
if not isinstance(entry, dict) or not verify_entry_hmac(entry):
_logger.warning("Workday penalty file is not a signed object")
return None
return entry
def workday_penalty_today(now: datetime | None = None) -> bool:
"""Whether today lost its relaxed-day leniency to a missed prior ring."""
entry = _read_verified()
if entry is None:
return False
return entry.get("penalty_date") == today_str(now)

View File

@ -79,4 +79,10 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
"leetcode_ledger.json",
("_leetcode_bonus.LEETCODE_LEDGER_FILE",),
),
# wake-alarm's workday-stick file. A test that forgot this would read
# the developer's real penalty state.
(
"workday_penalty.json",
("_constants.WORKDAY_PENALTY_FILE", "_workday_penalty.WORKDAY_PENALTY_FILE"),
),
)

View File

@ -89,6 +89,20 @@ class TestIsRelaxedDay:
result = is_relaxed_day()
assert isinstance(result, bool)
def test_a_workday_penalty_overrides_a_relaxed_day(self) -> None:
"""A missed prior-day ring costs Tue/Wed/Thu their leniency."""
target = "screen_locker._weekly_check.workday_penalty_today"
with patch(target, return_value=True):
assert is_relaxed_day(today=_dt(1)) is False
def test_an_enforced_day_is_unaffected_by_the_penalty_check(self) -> None:
"""The penalty is never even consulted on a day that was already enforced."""
with patch(
"screen_locker._weekly_check.workday_penalty_today",
side_effect=AssertionError("must not be called"),
):
assert is_relaxed_day(today=_dt(0)) is False
# ---------------------------------------------------------------------------
# count_weekly_workouts

View File

@ -0,0 +1,62 @@
"""screen_locker._workday_penalty — the stick file, read and never re-derived."""
from __future__ import annotations
from datetime import datetime
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
from gatelock.log_integrity import compute_entry_hmac
import pytest
from screen_locker import _workday_penalty
from screen_locker._workday_penalty import workday_penalty_today
if TYPE_CHECKING:
from pathlib import Path
NOW = datetime(2026, 9, 23, 9, 0).astimezone()
def _write(entry: dict[str, object], *, sign: bool = True) -> Path:
if sign:
entry["hmac"] = compute_entry_hmac(entry)
path = _workday_penalty.WORKDAY_PENALTY_FILE
path.write_text(json.dumps(entry))
return path
@pytest.fixture(autouse=True)
def _signing_key(tmp_path: Path) -> None:
key = tmp_path / "hmac.key"
key.write_bytes(b"1" * 32)
with patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key):
yield
class TestVerdict:
def test_todays_penalty_applies(self) -> None:
_write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"})
assert workday_penalty_today(NOW) is True
def test_a_different_dates_penalty_does_not_apply(self) -> None:
_write({"penalty_date": "2026-09-24", "issued_date": "2026-09-23"})
assert workday_penalty_today(NOW) is False
def test_missing_unreadable_tampered_are_all_no_penalty(
self, tmp_path: Path
) -> None:
assert workday_penalty_today(NOW) is False
path = _write({"penalty_date": "2026-09-23"}, sign=False)
assert workday_penalty_today(NOW) is False
path.write_text("not json")
assert workday_penalty_today(NOW) is False
path.write_text("[]")
assert workday_penalty_today(NOW) is False
path.unlink()
assert workday_penalty_today(NOW) is False
def test_default_now_is_used_when_unset(self) -> None:
# Not asserting a value -- just that omitting `now` does not raise.
assert workday_penalty_today() is False