diff --git a/docs/superpowers/contracts/screen-locker-scheduled-skip-2026-05.json b/docs/superpowers/contracts/screen-locker-scheduled-skip-2026-05.json new file mode 100644 index 0000000..50dd89b --- /dev/null +++ b/docs/superpowers/contracts/screen-locker-scheduled-skip-2026-05.json @@ -0,0 +1,17 @@ +{ + "title": "screen_locker: add scheduled-skip date mechanism", + "objective": "Allow specific calendar dates to bypass the screen lock entirely. Dates are stored in scheduled_skips.json as a JSON list of YYYY-MM-DD strings. When today's date is found in that list the locker exits with code 0 without displaying the lock UI. Also switches rtcwake from -m no to -m disk so the machine hibernates immediately when scheduling a morning alarm.", + "acceptance_criteria": [ + "If scheduled_skips.json is absent or today's date is not listed, the locker runs normally", + "If today's date is in scheduled_skips.json the locker exits 0 immediately with a log message", + "Malformed or missing JSON does not crash the locker", + "All screen_locker tests pass with 100% branch coverage", + "pre-commit passes on all changed files" + ], + "out_of_scope": [ + "GUI for managing skip dates", + "Remote or push-based skip date updates", + "Time-of-day granularity (whole-day only)" + ], + "verifier": "pytest python_pkg/screen_locker/tests/ --cov=python_pkg.screen_locker --cov-branch --cov-fail-under=100; pre-commit run --files " +} diff --git a/docs/superpowers/evidence/screen-locker-scheduled-skip-2026-05.json b/docs/superpowers/evidence/screen-locker-scheduled-skip-2026-05.json new file mode 100644 index 0000000..0d76eda --- /dev/null +++ b/docs/superpowers/evidence/screen-locker-scheduled-skip-2026-05.json @@ -0,0 +1,40 @@ +{ + "intent": "Add a scheduled-skip mechanism to screen_lock.py: allow specific calendar dates (stored in scheduled_skips.json) to bypass the daily lock entirely, so the locker exits cleanly when today's date is in the list.", + "scope": [ + "python_pkg/screen_locker/_constants.py", + "python_pkg/screen_locker/_shutdown.py", + "python_pkg/screen_locker/screen_lock.py", + "python_pkg/screen_locker/tests/conftest.py", + "python_pkg/screen_locker/tests/test_scheduled_skip.py", + "python_pkg/screen_locker/scheduled_skips.json" + ], + "changes": [ + "Added SCHEDULED_SKIPS_FILE constant to _constants.py pointing to scheduled_skips.json", + "Updated _shutdown.py: changed rtcwake -m no to rtcwake -m disk so the RTC alarm is set and the machine hibernates immediately (bedroom use)", + "Added _is_scheduled_skip_today() method to ScreenLocker in screen_lock.py: reads scheduled_skips.json, parses as JSON list of YYYY-MM-DD strings, checks today's UTC date", + "Added call to _is_scheduled_skip_today() at the top of _check_non_verify_exits(): if today is a skip day, log and exit 0", + "Added test_scheduled_skip.py with full branch coverage: absent file, empty list, today present, today absent, malformed JSON", + "Updated tests/conftest.py to expose SCHEDULED_SKIPS_FILE path for test isolation", + "Added scheduled_skips.json with initial skip dates" + ], + "verification": [ + { + "command": "pre-commit run --files python_pkg/screen_locker/_constants.py python_pkg/screen_locker/_shutdown.py python_pkg/screen_locker/screen_lock.py python_pkg/screen_locker/tests/conftest.py python_pkg/screen_locker/tests/test_scheduled_skip.py", + "result": "All hooks passed", + "evidence": "pre-commit run on 2026-05-22 returned Passed on all hooks including ruff, mypy, and pylint for all changed files" + }, + { + "command": "python -m pytest python_pkg/screen_locker/tests/ --cov=python_pkg.screen_locker --cov-branch --cov-fail-under=100 -q", + "result": "All tests passed, 100% branch coverage", + "evidence": "pytest run on 2026-05-22 showed all tests passed with 100% branch coverage across all screen_locker modules" + } + ], + "risks": [ + "scheduled_skips.json dates become stale over time — old dates remain harmless (they never match today again)", + "If scheduled_skips.json contains invalid JSON the locker silently continues (no skip); malformed file does not cause a crash" + ], + "rollback": [ + "Remove the _is_scheduled_skip_today() call from _check_non_verify_exits() to disable the feature", + "Revert _shutdown.py rtcwake flag from -m disk back to -m no if hibernate is undesirable" + ] +} diff --git a/python_pkg/screen_locker/_constants.py b/python_pkg/screen_locker/_constants.py index fa31d40..892b887 100644 --- a/python_pkg/screen_locker/_constants.py +++ b/python_pkg/screen_locker/_constants.py @@ -46,3 +46,5 @@ SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json" # Persistent sick-day history (rate-limit, debt, commitments, justifications). # Distinct from SICK_DAY_STATE_FILE which is a one-day shutdown-config snapshot. SICK_HISTORY_FILE = Path(__file__).resolve().parent / "sick_history.json" +# JSON list of ISO date strings ("YYYY-MM-DD") for which the screen lock is skipped. +SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json" diff --git a/python_pkg/screen_locker/_shutdown.py b/python_pkg/screen_locker/_shutdown.py index 65bd91e..89692ef 100644 --- a/python_pkg/screen_locker/_shutdown.py +++ b/python_pkg/screen_locker/_shutdown.py @@ -293,8 +293,10 @@ class ShutdownMixin: def _schedule_rtcwake() -> bool: """Set rtcwake to power on the PC after WAKE_AFTER_HOURS. - Uses ``rtcwake -m no`` so the system can shut down normally while - the RTC alarm remains set. + Uses ``rtcwake -m disk`` to hibernate immediately while programming + the RTC to restore power at wake_epoch. Hibernate is completely + silent and dark (state written to swap file), making it suitable + when the PC is in a bedroom. Returns: True if rtcwake was set successfully, False otherwise. @@ -304,7 +306,7 @@ class ShutdownMixin: "/usr/bin/sudo", RTCWAKE_BIN, "-m", - "no", + "disk", "-t", str(wake_epoch), ] diff --git a/python_pkg/screen_locker/scheduled_skips.json b/python_pkg/screen_locker/scheduled_skips.json new file mode 100644 index 0000000..4afbae5 --- /dev/null +++ b/python_pkg/screen_locker/scheduled_skips.json @@ -0,0 +1 @@ +["2026-05-19", "2026-05-20", "2026-05-21"] diff --git a/python_pkg/screen_locker/screen_lock.py b/python_pkg/screen_locker/screen_lock.py index 4359e81..a395575 100755 --- a/python_pkg/screen_locker/screen_lock.py +++ b/python_pkg/screen_locker/screen_lock.py @@ -27,6 +27,7 @@ from python_pkg.screen_locker._constants import ( MIN_WORKOUT_DURATION_MINUTES, PHONE_PENALTY_DELAY_DEMO, PHONE_PENALTY_DELAY_PRODUCTION, + SCHEDULED_SKIPS_FILE, SICK_LOCKOUT_SECONDS, STRONGLIFTS_DB_REMOTE, ) @@ -54,6 +55,7 @@ __all__ = [ "MIN_WORKOUT_DURATION_MINUTES", "PHONE_PENALTY_DELAY_DEMO", "PHONE_PENALTY_DELAY_PRODUCTION", + "SCHEDULED_SKIPS_FILE", "SICK_LOCKOUT_SECONDS", "STRONGLIFTS_DB_REMOTE", "ScreenLocker", @@ -185,6 +187,9 @@ class ScreenLocker( def _check_non_verify_exits(self) -> None: """Check all normal (non-verify) startup early-exit conditions.""" + if self._is_scheduled_skip_today(): + _logger.info("Today is a scheduled skip day. Skipping screen lock.") + sys.exit(0) if self._is_early_bird_log() and not self._is_early_bird_time(): if self._try_auto_upgrade_early_bird(): _logger.info( @@ -484,6 +489,18 @@ class ScreenLocker( except (OSError, json.JSONDecodeError): return {} + def _is_scheduled_skip_today(self) -> bool: + """Return True if today's date is listed in the scheduled skips file.""" + if not SCHEDULED_SKIPS_FILE.exists(): + return False + try: + with SCHEDULED_SKIPS_FILE.open() as f: + skips = json.load(f) + except (OSError, json.JSONDecodeError): + return False + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + return today in skips + def save_workout_log(self) -> None: """Save workout data to log file with HMAC signature.""" logs = self._load_existing_logs() diff --git a/python_pkg/screen_locker/tests/conftest.py b/python_pkg/screen_locker/tests/conftest.py index c92b2e9..43818f9 100644 --- a/python_pkg/screen_locker/tests/conftest.py +++ b/python_pkg/screen_locker/tests/conftest.py @@ -95,6 +95,17 @@ def _isolate_sick_history(tmp_path: Path) -> Iterator[None]: yield +@pytest.fixture(autouse=True) +def _isolate_scheduled_skips(tmp_path: Path) -> Iterator[None]: + """Redirect SCHEDULED_SKIPS_FILE to tmp_path so tests use a clean file.""" + target = tmp_path / "scheduled_skips.json" + with patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + target, + ): + yield + + @pytest.fixture def mock_tk() -> Generator[MagicMock]: """Mock tkinter module for testing without display.""" diff --git a/python_pkg/screen_locker/tests/test_scheduled_skip.py b/python_pkg/screen_locker/tests/test_scheduled_skip.py new file mode 100644 index 0000000..cc3b6a7 --- /dev/null +++ b/python_pkg/screen_locker/tests/test_scheduled_skip.py @@ -0,0 +1,195 @@ +"""Tests for scheduled skip date feature in screen_lock.py.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + from python_pkg.screen_locker.screen_lock import ScreenLocker + + +class TestIsScheduledSkipToday: + """Tests for ScreenLocker._is_scheduled_skip_today.""" + + def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker: + return create_locker(mock_tk, tmp_path) + + def test_returns_false_when_file_absent( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when scheduled_skips.json does not exist.""" + locker = self._make_locker(mock_tk, tmp_path) + skip_file = tmp_path / "scheduled_skips.json" + with patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + skip_file, + ): + assert locker._is_scheduled_skip_today() is False + + def test_returns_true_when_today_listed( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns True when today's date is in the skips list.""" + locker = self._make_locker(mock_tk, tmp_path) + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + skip_file = tmp_path / "scheduled_skips.json" + skip_file.write_text(json.dumps([today])) + with patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + skip_file, + ): + assert locker._is_scheduled_skip_today() is True + + def test_returns_false_when_today_not_listed( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when today's date is not in the skips list.""" + locker = self._make_locker(mock_tk, tmp_path) + skip_file = tmp_path / "scheduled_skips.json" + skip_file.write_text(json.dumps(["1999-01-01", "2000-06-15"])) + with patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + skip_file, + ): + assert locker._is_scheduled_skip_today() is False + + def test_returns_false_on_corrupt_json( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when the skips file contains invalid JSON.""" + locker = self._make_locker(mock_tk, tmp_path) + skip_file = tmp_path / "scheduled_skips.json" + skip_file.write_text("{not valid json}") + with patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + skip_file, + ): + assert locker._is_scheduled_skip_today() is False + + def test_returns_false_on_read_error( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when the skips file cannot be read (OSError).""" + locker = self._make_locker(mock_tk, tmp_path) + skip_file = tmp_path / "scheduled_skips.json" + skip_file.write_text("[]") + with ( + patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + skip_file, + ), + patch("builtins.open", side_effect=OSError("permission denied")), + ): + assert locker._is_scheduled_skip_today() is False + + def test_empty_list_returns_false( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False for an empty skips list.""" + locker = self._make_locker(mock_tk, tmp_path) + skip_file = tmp_path / "scheduled_skips.json" + skip_file.write_text("[]") + with patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + skip_file, + ): + assert locker._is_scheduled_skip_today() is False + + +class TestScheduledSkipEarlyExit: + """Tests for _check_non_verify_exits behaviour with scheduled skips.""" + + @staticmethod + def _write_today_skip(tmp_path: Path) -> None: + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + skip_file = tmp_path / "scheduled_skips.json" + skip_file.write_text(json.dumps([today])) + + def test_exits_on_scheduled_skip_day( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Screen locker calls sys.exit(0) when today is a scheduled skip.""" + self._write_today_skip(tmp_path) + mock_sys_exit.side_effect = SystemExit(0) + + with pytest.raises(SystemExit): + create_locker(mock_tk, tmp_path) + + mock_sys_exit.assert_called_once_with(0) + + def test_does_not_exit_when_not_scheduled_skip( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Screen locker proceeds normally when today is not a scheduled skip.""" + # No file written — _is_scheduled_skip_today returns False + locker = create_locker(mock_tk, tmp_path) + + mock_sys_exit.assert_not_called() + assert locker is not None + + def test_scheduled_skip_takes_precedence_over_has_logged( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Scheduled skip exits before has_logged or other checks run.""" + self._write_today_skip(tmp_path) + mock_sys_exit.side_effect = SystemExit(0) + + with pytest.raises(SystemExit): + create_locker(mock_tk, tmp_path, has_logged=False) + + mock_sys_exit.assert_called_once_with(0) + + def test_verify_only_mode_ignores_scheduled_skip( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """verify_only mode does not consult scheduled skips.""" + self._write_today_skip(tmp_path) + + # verify_only exits because no sick day log, not because of scheduled skip + create_locker( + mock_tk, + tmp_path, + verify_only=True, + is_sick_day_log=False, + ) + + mock_sys_exit.assert_called_once_with(0)