diff --git a/prompts/TODO-dopamine-ux-04-screen-locker.md b/prompts/TODO-dopamine-ux-04-screen-locker.md index b5f9dfc..fec1b73 100644 --- a/prompts/TODO-dopamine-ux-04-screen-locker.md +++ b/prompts/TODO-dopamine-ux-04-screen-locker.md @@ -53,7 +53,7 @@ screen-locker computes a real workout streak and celebratory reward text, then throws almost all of it away. Verified 2026-08-16: - `StatusSnapshot` (`screen_locker/_status_types.py` ~:88-101) carries populated - `streak`, `bonus_hours_this_week` and `early_bird_extended` fields, filled by + `streak` and `early_bird_extended` fields, filled by `_status_data.py` ~:210-214. **Grep confirms no renderer reads them.** - `_extra_benefits.py` `process_week_transition()` (~:82, reward strings built at ~:130-139) produces fully-formed celebratory sentences — and @@ -81,8 +81,7 @@ Primary: - `screen_locker/_status_types.py` — `StatusSnapshot` (~:88-101), the fields you are rendering. Read-only for this task. - `screen_locker/_extra_benefits.py` (179 lines) — `process_week_transition()` - and its reward strings; `current_streak()` ~:154, - `weekly_shutdown_bonus_hours()` ~:159, `has_extended_early_bird()` ~:171. + and its reward strings; `current_streak()`, `has_extended_early_bird()`. - `screen_locker/_startup_checks.py` ~:57-58 — where the reward strings are currently discarded into a log call. - `screen_locker/_unlock_view.py` (72 lines, lots of headroom) — the existing @@ -91,8 +90,8 @@ Primary: ## must - **Render the already-populated fields.** Add `_section_streak` to - `_status_sections.py` showing the weekly streak, this week's bonus hours, and - the extended-early-bird state, and call it from `status_view.py`'s `render()`. + `_status_sections.py` showing the weekly streak and the extended-early-bird + state, and call it from `status_view.py`'s `render()`. Zero new state, zero conftest change, zero new dependency. - **Stop discarding the reward strings.** `process_week_transition()` returns human-readable celebratory sentences that currently only reach the log. Surface @@ -106,17 +105,20 @@ Primary: {phone_verified, runnerup_verified, manual_workout}`). Do not round, pad, or "encourage" the number. -- must not: **make any reward reduce enforcement.** This is recorded twice in the +- must not: **make any reward reduce enforcement.** This is recorded in the repo as a deliberate decision: - > `_startup_checks.py` ~:88-93 — "there is no banked 'skip a workout' credit — + > `_startup_checks.py` — "there is no banked 'skip a workout' credit — > that mechanic works against the goal of maximizing weekly workouts, so it was - > removed in favor of a shutdown-time-only reward" - > `_extra_benefits.py` ~:5-8 — "This never reduces enforcement … it only grants - > extra comfort time on top of a floor you still have to earn each day." + > removed." - Every reward here is **display or comfort-time only**. Do not reintroduce a + Every reward here is **display only**. Do not reintroduce a banked skip, a streak freeze, a grace day, or any mechanic that lowers the floor. A dopamine loop that lets you skip the workout defeats the app. +- must not: **grant shutdown time for a streak, a week or a milestone.** The + weekly banked shutdown bonus (and the 4-week `_MILESTONE_INTERVAL` +1h) was + removed on purpose (2026-10-10): every shutdown minute comes from the + `earned_time` ladder and stops at its daily ceiling. Rewards here are display + only — no "+Nh" weekly reward, no bonus-hours field, no new shutdown source. - must not: add **sound** to any enforcement or lock path. screen-locker is a self-restriction tool; a chime on lock is punishment audio, and a chime on unlock trains the wrong association. This repo stays silent. (Sound is @@ -141,7 +143,7 @@ Primary: ## done 1. Opening the status window (tray icon → click, or `screen-locker-status`) shows - the current streak, bonus hours and early-bird state — with the real values + the current streak and early-bird state — with the real values from `extra_benefits_state.json`, not placeholders. 2. A week-transition milestone reaches the UI instead of only `_logger.info`. 3. `cd ~/src/screen-locker && python -m pytest` passes. @@ -160,8 +162,8 @@ python -m screen_locker --status # CLI: confirm the values it prints screen-locker-status # the Tk window: confirm they render ``` -Paste both outputs. The CLI already prints `streak`/`bonus_hours` (`_status.py` -~:138-141), so it is your ground truth: the window must show the **same numbers**. +Paste both outputs. The CLI already prints the streak and early-bird state +(`_status.py`), so it is your ground truth: the window must show the **same numbers**. If they disagree, the renderer is wrong, not the data. Current real state for comparison: `screen_locker/extra_benefits_state.json` and @@ -188,10 +190,11 @@ Current real state for comparison: `screen_locker/extra_benefits_state.json` and - All persistence is **JSON files, no sqlite**; paths declared in `screen_locker/_constants.py`. The streak store is `screen_locker/extra_benefits_state.json` (`consecutive_5plus_weeks`, - `last_processed_iso_week`, `weekly_shutdown_bonus_hours`, - `extended_early_bird_iso_weeks`). -- Thresholds live in `_extra_benefits.py`: `_MILESTONE_INTERVAL = 4` (4-week - milestone), `_BONUS_THRESHOLD = 5` (workouts per week). + `last_processed_iso_week`, `extended_early_bird_iso_weeks`; a legacy + `weekly_shutdown_bonus_hours` map is ignored and dropped on the next write). +- The threshold lives in `_extra_benefits.py`: `_BONUS_THRESHOLD = 5` (workouts + per week, for the streak and the early-bird extension). There is no milestone + constant any more. - **There is no config system** — no argparse, no env vars, no config file. Every tunable is a module-level constant. An opt-out toggle has no existing home; for a self-restriction tool, requiring a code edit to disable is arguably diff --git a/screen_locker/_extra_benefits.py b/screen_locker/_extra_benefits.py index 7ca6e07..c3d66e0 100644 --- a/screen_locker/_extra_benefits.py +++ b/screen_locker/_extra_benefits.py @@ -4,12 +4,11 @@ Tracks: - Consecutive weeks with 5+ workouts (streak counter). - ISO weeks in which the early-bird window is extended to 09:00. -A 5+ workout week no longer banks shutdown-time hours for the following week -(dropped 2026-10-10; 2026-W41 was the last week to receive any). The -``weekly_shutdown_bonus_hours`` map is still *read* by -:func:`weekly_shutdown_bonus_hours`, so hours already banked for the current -week keep applying, and ``scripts/restore_and_bonus.py`` can still bank a -one-off compensation into it — but the week transition never writes it. +There is no weekly shutdown-time bonus (dropped entirely 2026-10-10, hours +already banked for 2026-W41 included): every shutdown minute comes from the +``earned_time`` ladder and stops at its daily ceiling. A legacy +``weekly_shutdown_bonus_hours`` map in an old state file is ignored, and +dropped at the next week transition's write. State is persisted in ``extra_benefits_state.json`` next to this file. """ @@ -29,6 +28,8 @@ if TYPE_CHECKING: _logger = logging.getLogger(__name__) _BONUS_THRESHOLD = 5 # workouts/week required to extend the streak + early-bird +# The removed weekly shutdown bonus's map; ignored on read, dropped on write. +_LEGACY_BONUS_KEY = "weekly_shutdown_bonus_hours" def _load_state(state_file: Path) -> dict[str, Any]: @@ -116,13 +117,12 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]: streak = 0 # Merge onto the loaded state rather than rebuilding it from these three - # keys: the legacy ``weekly_shutdown_bonus_hours`` map (still read, and - # still written by restore_and_bonus.py) and sibling fields written by the - # recovery scripts (e.g. ``shutdown_bonus_granted_for``, the guard that - # stops a compensation bonus being granted twice) live in this file too, - # and rebuilding would drop them at the next weekly rollover — losing this - # week's banked hours and silently re-arming a double-grant. + # keys: sibling fields written by the recovery scripts (e.g. + # ``shutdown_bonus_granted_for``) live in this file too, and rebuilding + # would drop them at the next weekly rollover. Only the dead weekly-bonus + # map is dropped, so no reader can mistake it for live state. updated = dict(state) + updated.pop(_LEGACY_BONUS_KEY, None) updated.update( { "consecutive_5plus_weeks": streak, @@ -139,23 +139,6 @@ def current_streak(state_file: Path) -> int: return int(_load_state(state_file).get("consecutive_5plus_weeks", 0)) -def weekly_shutdown_bonus_hours( - state_file: Path, *, today: datetime | None = None -) -> int: - """Return the banked shutdown-time bonus (hours) for the current ISO week. - - The week transition stopped banking these after 2026-W41; what is left is - any hours already banked for this week, or a manual compensation written - by ``scripts/restore_and_bonus.py``. - """ - now = today if today is not None else datetime.now(tz=UTC).astimezone() - current_week_str = _current_iso_week(now) - bonus_hours: dict[str, int] = _load_state(state_file).get( - "weekly_shutdown_bonus_hours", {} - ) - return int(bonus_hours.get(current_week_str, 0)) - - def has_extended_early_bird(state_file: Path, *, today: datetime | None = None) -> bool: """Return True if the current ISO week has an extended early-bird window (09:00).""" now = today if today is not None else datetime.now(tz=UTC).astimezone() diff --git a/screen_locker/_poke_credit.py b/screen_locker/_poke_credit.py index 0049132..f96319a 100644 --- a/screen_locker/_poke_credit.py +++ b/screen_locker/_poke_credit.py @@ -80,21 +80,17 @@ class PokeLocker(ScreenLocker): """Run the new-day reset the locker's startup runs, in the same order. Mirrors ``StartupChecksMixin._check_non_verify_exits``, not - ``sync_now`` (which never resets): the week transition must be - processed BEFORE the reset, and any banked weekly bonus layered on only - when this call did the reset. If the poke stamped the day without them, - the locker's own reset later would be a no-op and that day's banked - bonus would be lost. + ``sync_now`` (which never resets): the week transition (streak, + early-bird window) first, then the daily reset. """ for reward in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE): _logger.info("Weekly reward: %s", reward) - if reset_to_base_if_new_day( + reset_to_base_if_new_day( SHUTDOWN_BASE_FILE, self, sick_day_state_file=SICK_DAY_STATE_FILE, log_file=self.log_file, - ): - self._apply_weekly_shutdown_bonus() + ) def credit_written( self, entry: dict[str, str], prior_entries: list[dict[str, object]] diff --git a/screen_locker/_startup_checks.py b/screen_locker/_startup_checks.py index 395f312..11a5b85 100644 --- a/screen_locker/_startup_checks.py +++ b/screen_locker/_startup_checks.py @@ -105,19 +105,16 @@ class StartupChecksMixin(SyncMixin): self._record_skip("scheduled_skip_day", "Today is a scheduled skip day.") return # Award streak / EB-extension rewards from last week before the daily - # reset. The transition no longer banks shutdown hours; any already - # banked for this week are layered on by _apply_weekly_shutdown_bonus. + # reset. No week carries a shutdown bonus any more (2026-10-10). for reward_msg in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE): _logger.info("Weekly reward: %s", reward_msg) - # Reset shutdown config to base (19:00) at the start of each new day, - # then layer this week's banked bonus (if any) back on the fresh base. - if reset_to_base_if_new_day( + # Reset shutdown config to the day's base plus what today already earned. + reset_to_base_if_new_day( SHUTDOWN_BASE_FILE, self, sick_day_state_file=SICK_DAY_STATE_FILE, log_file=self.log_file, - ): - self._apply_weekly_shutdown_bonus() + ) # A LeetCode solve or a reading credit usually lands hours after the # reset; this 5-minute tick turns it into the extra hour the same day. apply_flat_bonuses_if_new(SHUTDOWN_BASE_FILE, self) @@ -160,8 +157,7 @@ class StartupChecksMixin(SyncMixin): # Only remaining same-day skip: genuine extreme heat. Sick days go # through the justification flow instead; there is no banked # "skip a workout" credit — that mechanic works against the goal of - # maximizing weekly workouts, so it was removed in favor of a - # shutdown-time-only reward (see _apply_weekly_shutdown_bonus). + # maximizing weekly workouts, so it was removed. # A logged workout outranks every rung above, whatever their order. if refuse_to_lock_over_logged_workout(self.has_logged_today, self._record_skip): return diff --git a/screen_locker/_status.py b/screen_locker/_status.py index 0cd9c60..a24a7b2 100644 --- a/screen_locker/_status.py +++ b/screen_locker/_status.py @@ -14,7 +14,6 @@ from screen_locker._earned import hhmm from screen_locker._extra_benefits import ( current_streak, has_extended_early_bird, - weekly_shutdown_bonus_hours, ) from screen_locker._log_io import load_workout_log from screen_locker._sick_tracker import load_history @@ -41,7 +40,7 @@ def _load_extra_benefits() -> dict: except (OSError, ValueError) as exc: _logger.warning( "Could not read extra-benefits state from %s: %s — the status view " - "will show a 0 streak and no bonus hours, which may be wrong", + "will show a 0 streak, which may be wrong", EXTRA_BENEFITS_FILE, exc, ) @@ -116,7 +115,6 @@ def run_status(locker: ScreenLocker) -> None: print() # Extra benefits summary - bonus_hours = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE) streak = current_streak(EXTRA_BENEFITS_FILE) eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE) eb_str = "Yes — until 09:00" if eb_ext else "No" @@ -138,7 +136,6 @@ def run_status(locker: ScreenLocker) -> None: else: heat_str = "0" - print(f" Shutdown bonus (this wk): {bonus_hours}h") print(f" Streak (5+ wks) : {streak}") print(f" Early-bird extended : {eb_str}") print(f" Heat skips (month) : {heat_str}") diff --git a/screen_locker/_status_data.py b/screen_locker/_status_data.py index c01381f..46c9de0 100644 --- a/screen_locker/_status_data.py +++ b/screen_locker/_status_data.py @@ -54,7 +54,6 @@ from screen_locker._constants import ( from screen_locker._extra_benefits import ( current_streak, has_extended_early_bird, - weekly_shutdown_bonus_hours, ) from screen_locker._log_io import load_workout_log from screen_locker._morning_session import has_workout_skip_today @@ -203,14 +202,10 @@ def gather_status( return StatusSnapshot( today=days[-1], week=week, - bonus_hours_this_week=weekly_shutdown_bonus_hours( - extra_benefits_file, today=today_local - ), streak=current_streak(extra_benefits_file), early_bird_extended=early_bird_extended, shutdown=_shutdown_projection( shutdown_config_file=shutdown_config_file, - extra_benefits_file=extra_benefits_file, today_local=today_local, ), lock_explanation=lock_explanation, diff --git a/screen_locker/_status_projection.py b/screen_locker/_status_projection.py index f764cc0..a0d1c56 100644 --- a/screen_locker/_status_projection.py +++ b/screen_locker/_status_projection.py @@ -14,7 +14,6 @@ from datetime import date, datetime, timedelta from typing import TYPE_CHECKING from screen_locker._earned import hhmm -from screen_locker._extra_benefits import weekly_shutdown_bonus_hours from screen_locker._shutdown import read_shutdown_config from screen_locker._shutdown_base import base_minutes from screen_locker._status_types import ShutdownProjection, ShutdownProjectionDay @@ -31,14 +30,9 @@ _RELAXED_DAY_EXPLANATION = ( ) _WEEKDAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") _MON_WED_WEEKDAYS = frozenset({0, 1, 2}) -# A banked weekly bonus (legacy, or a restore_and_bonus.py compensation) is in -# whole hours. -_MINUTES_PER_HOUR = 60 -def _week_rows( - monday: date, bonus: int, *, speculative: bool -) -> tuple[ShutdownProjectionDay, ...]: +def _week_rows(monday: date, *, speculative: bool) -> tuple[ShutdownProjectionDay, ...]: """Build 7 labeled rows, Mon-Sun from ``monday``, each on its own day's base. Each row asks ``base_minutes`` for its own date, so a registry cut dated @@ -47,7 +41,7 @@ def _week_rows( return tuple( ShutdownProjectionDay( label=label, - minutes=base_minutes(monday + timedelta(days=weekday)) + bonus, + minutes=base_minutes(monday + timedelta(days=weekday)), speculative=speculative, ) for weekday, label in enumerate(_WEEKDAY_LABELS) @@ -57,23 +51,17 @@ def _week_rows( def _shutdown_projection( *, shutdown_config_file: Path, - extra_benefits_file: Path, today_local: datetime, ) -> ShutdownProjection: """Build the shutdown-time projection: tonight, rest of week, next week. - Next week is the plain per-day base: a 5+ workout week no longer banks - shutdown hours for the week after (only the early-bird extension). + Every row is the plain per-day base: no week carries a banked shutdown + bonus (dropped 2026-10-10). """ tonight = read_shutdown_config(shutdown_config_file) - bonus = ( - weekly_shutdown_bonus_hours(extra_benefits_file, today=today_local) - * _MINUTES_PER_HOUR - ) monday = today_local.date() - timedelta(days=today_local.weekday()) - rest_of_week = _week_rows(monday, bonus, speculative=False) - - next_week_preview = _week_rows(monday + timedelta(weeks=1), 0, speculative=True) + rest_of_week = _week_rows(monday, speculative=False) + next_week_preview = _week_rows(monday + timedelta(weeks=1), speculative=True) return ShutdownProjection( tonight=tonight, diff --git a/screen_locker/_status_types.py b/screen_locker/_status_types.py index c3544d1..41f841d 100644 --- a/screen_locker/_status_types.py +++ b/screen_locker/_status_types.py @@ -94,7 +94,6 @@ class StatusSnapshot: today: DayStatus week: WeeklySummary - bonus_hours_this_week: int streak: int early_bird_extended: bool shutdown: ShutdownProjection diff --git a/screen_locker/_sync_mixin.py b/screen_locker/_sync_mixin.py index b08631a..898b5c0 100644 --- a/screen_locker/_sync_mixin.py +++ b/screen_locker/_sync_mixin.py @@ -10,10 +10,9 @@ from __future__ import annotations import logging -from screen_locker._constants import EXTRA_BENEFITS_FILE, SHUTDOWN_BASE_FILE +from screen_locker._constants import SHUTDOWN_BASE_FILE from screen_locker._earned import span from screen_locker._earner_units import sync_path_unit -from screen_locker._extra_benefits import weekly_shutdown_bonus_hours from screen_locker._manual_push import push_pc_workouts from screen_locker._manual_sync import ingest_manual_records from screen_locker._session_sync import ingest_session_records @@ -27,9 +26,6 @@ __all__ = ["SyncMixin"] _logger = logging.getLogger(__name__) -# A banked weekly bonus is in whole hours; the schedule takes minutes. -_MINUTES_PER_HOUR = 60 - class SyncMixin: """Publish, ingest and credit manual workouts synced between devices.""" @@ -136,14 +132,3 @@ class SyncMixin: "Auto-filled %d RunnerUp workout(s) from TCX exports.", n_filled ) self.workout_data = {} - - def _apply_weekly_shutdown_bonus(self) -> None: - """Layer this week's banked shutdown bonus (if any) on the fresh base. - - Week transitions stopped banking hours after 2026-W41; only hours - already banked for this week or a restore_and_bonus.py compensation - remain to apply. - """ - bonus = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE) - if bonus > 0 and self._adjust_shutdown_time_by(bonus * _MINUTES_PER_HOUR): - _logger.info("Weekly bonus: +%dh shutdown time this week.", bonus) diff --git a/screen_locker/_weekly_check.py b/screen_locker/_weekly_check.py index a031994..7b66a71 100644 --- a/screen_locker/_weekly_check.py +++ b/screen_locker/_weekly_check.py @@ -196,7 +196,7 @@ def count_weekly_workouts( recorded twice is credited once. Manual workouts are rate-limited separately by the manual-workout budget (:mod:`screen_locker._manual_workout`), not by a collapse here. Feeds both - the weekly lock minimum (:func:`has_weekly_minimum`) and the banked bonus. + the weekly lock minimum (:func:`has_weekly_minimum`) and the weekly streak. Args: log_file: Path to ``log.json``. diff --git a/screen_locker/tests/_isolated_state.py b/screen_locker/tests/_isolated_state.py index 24a80b3..163503d 100644 --- a/screen_locker/tests/_isolated_state.py +++ b/screen_locker/tests/_isolated_state.py @@ -27,7 +27,7 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = ( ( "_constants.EXTRA_BENEFITS_FILE", "_startup_checks.EXTRA_BENEFITS_FILE", - "_sync_mixin.EXTRA_BENEFITS_FILE", + "_poke_credit.EXTRA_BENEFITS_FILE", "_status.EXTRA_BENEFITS_FILE", ), ), diff --git a/screen_locker/tests/_status_view_helpers.py b/screen_locker/tests/_status_view_helpers.py index 052303d..25723be 100644 --- a/screen_locker/tests/_status_view_helpers.py +++ b/screen_locker/tests/_status_view_helpers.py @@ -134,7 +134,6 @@ def _snapshot(**overrides: object) -> StatusSnapshot: default = StatusSnapshot( today=_day(), week=_week(), - bonus_hours_this_week=0, streak=0, early_bird_extended=False, shutdown=_shutdown(), diff --git a/screen_locker/tests/test_extra_benefits.py b/screen_locker/tests/test_extra_benefits.py index cf616af..d076095 100644 --- a/screen_locker/tests/test_extra_benefits.py +++ b/screen_locker/tests/test_extra_benefits.py @@ -29,8 +29,8 @@ class TestLoadState: def test_returns_parsed_state_when_file_valid(self, tmp_path: Path) -> None: """Valid JSON file returns the parsed dict.""" f = tmp_path / "state.json" - f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2026-W01": 3}})) - assert _load_state(f) == {"weekly_shutdown_bonus_hours": {"2026-W01": 3}} + f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2026-W01"]})) + assert _load_state(f) == {"extended_early_bird_iso_weeks": ["2026-W01"]} def test_returns_empty_on_oserror(self) -> None: """OSError during read is caught and returns empty dict (lines 33-34).""" @@ -52,10 +52,10 @@ class TestSaveState: def test_saves_state_to_file(self, tmp_path: Path) -> None: """Valid path writes JSON content (lines 39-41).""" f = tmp_path / "state.json" - _save_state(f, {"weekly_shutdown_bonus_hours": {"2026-W01": 2}}) - assert json.loads(f.read_text())["weekly_shutdown_bonus_hours"] == { - "2026-W01": 2 - } + _save_state(f, {"extended_early_bird_iso_weeks": ["2026-W02"]}) + assert json.loads(f.read_text())["extended_early_bird_iso_weeks"] == [ + "2026-W02" + ] def test_logs_warning_on_oserror(self) -> None: """OSError during write is caught as warning, does not raise (lines 42-43).""" @@ -95,7 +95,6 @@ class TestProcessWeekTransition: { "last_processed_iso_week": self._PAST_WEEK, "consecutive_5plus_weeks": 0, - "weekly_shutdown_bonus_hours": {}, "extended_early_bird_iso_weeks": [], } ) @@ -113,10 +112,10 @@ class TestProcessWeekTransition: assert not any("shutdown bonus" in r for r in rewards) state = json.loads(f.read_text()) assert state["consecutive_5plus_weeks"] == 1 - assert state["weekly_shutdown_bonus_hours"] == {} + assert "weekly_shutdown_bonus_hours" not in state def test_4_week_streak_banks_no_milestone_hour(self, tmp_path: Path) -> None: - """Streak reaching 4 adds no +1h; a legacy bonus map is left untouched.""" + """Streak reaching 4 adds no +1h; a legacy bonus map is dropped on write.""" f = tmp_path / "state.json" f.write_text( json.dumps( @@ -137,7 +136,7 @@ class TestProcessWeekTransition: assert "4-week streak" in rewards[0] state = json.loads(f.read_text()) assert state["consecutive_5plus_weeks"] == 4 - assert state["weekly_shutdown_bonus_hours"] == {"2020-W52": 2} + assert "weekly_shutdown_bonus_hours" not in state def test_marks_current_week_as_extended_early_bird(self, tmp_path: Path) -> None: """5+ workouts mark current ISO week as extended EB (line 91-92).""" diff --git a/screen_locker/tests/test_extra_benefits_part2.py b/screen_locker/tests/test_extra_benefits_part2.py index 2878a8e..d9bde96 100644 --- a/screen_locker/tests/test_extra_benefits_part2.py +++ b/screen_locker/tests/test_extra_benefits_part2.py @@ -1,4 +1,4 @@ -"""Tests for _extra_benefits (streak, legacy banked bonus, EB extension).""" +"""Tests for _extra_benefits (streak, legacy bonus key dropped, EB extension).""" from __future__ import annotations @@ -10,37 +10,12 @@ from unittest.mock import patch from screen_locker._extra_benefits import ( has_extended_early_bird, process_week_transition, - weekly_shutdown_bonus_hours, ) if TYPE_CHECKING: from pathlib import Path -class TestWeeklyShutdownBonusHours: - """Tests for weekly_shutdown_bonus_hours.""" - - def test_returns_zero_when_missing(self, tmp_path: Path) -> None: - """No state file → 0.""" - f = tmp_path / "state.json" - assert weekly_shutdown_bonus_hours(f) == 0 - - def test_returns_current_week_bonus(self, tmp_path: Path) -> None: - """Returns the banked bonus for the current ISO week.""" - now = datetime.now(tz=UTC).astimezone() - year, week, _ = now.isocalendar() - current_week = f"{year}-W{week:02d}" - f = tmp_path / "state.json" - f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {current_week: 3}})) - assert weekly_shutdown_bonus_hours(f) == 3 - - def test_ignores_other_weeks(self, tmp_path: Path) -> None: - """A bonus banked for a different ISO week is not returned.""" - f = tmp_path / "state.json" - f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2020-W01": 5}})) - assert weekly_shutdown_bonus_hours(f) == 0 - - class _FrozenDatetime(datetime): """``datetime`` whose ``now`` is pinned to Monday 2026-10-12 12:00 UTC.""" @@ -52,7 +27,7 @@ class _FrozenDatetime(datetime): class TestBonusDroppedAtW42: - """The 2026-W41 -> W42 rollover banks nothing; W41's hours stay readable.""" + """The 2026-W41 -> W42 rollover banks nothing and drops the legacy bonus map.""" _STATE: ClassVar[dict[str, object]] = { "consecutive_5plus_weeks": 3, @@ -81,23 +56,16 @@ class TestBonusDroppedAtW42: ) ] state = json.loads(f.read_text()) + # Sibling keys (the compensation guard) survive; only the dead map goes. assert state == { - **self._STATE, "consecutive_5plus_weeks": 4, "last_processed_iso_week": "2026-W42", "extended_early_bird_iso_weeks": ["2026-W41", "2026-W42"], + "shutdown_bonus_granted_for": ["2026-08-24"], } w42 = datetime(2026, 10, 12, 12, 0, tzinfo=UTC) - assert weekly_shutdown_bonus_hours(f, today=w42) == 0 assert has_extended_early_bird(f, today=w42) is True - def test_w41_banked_hours_still_apply_through_sunday(self, tmp_path: Path) -> None: - """Hours banked before the drop keep applying for the rest of W41.""" - f = tmp_path / "state.json" - f.write_text(json.dumps(self._STATE)) - sunday = datetime(2026, 10, 11, 21, 0, tzinfo=UTC) - assert weekly_shutdown_bonus_hours(f, today=sunday) == 2 - class TestHasExtendedEarlyBird: """Tests for has_extended_early_bird.""" diff --git a/screen_locker/tests/test_mcp.py b/screen_locker/tests/test_mcp.py index 6e245c2..a878d8f 100644 --- a/screen_locker/tests/test_mcp.py +++ b/screen_locker/tests/test_mcp.py @@ -58,7 +58,6 @@ def _snapshot() -> StatusSnapshot: week=WeeklySummary( days=(day,), counted_count=2, minimum=4, remaining=2, extra=0 ), - bonus_hours_this_week=0, streak=1, early_bird_extended=False, shutdown=ShutdownProjection( diff --git a/screen_locker/tests/test_poke_credit_day.py b/screen_locker/tests/test_poke_credit_day.py index 83a8e7b..546ab84 100644 --- a/screen_locker/tests/test_poke_credit_day.py +++ b/screen_locker/tests/test_poke_credit_day.py @@ -50,7 +50,7 @@ def credit(monkeypatch: pytest.MonkeyPatch) -> MagicMock: class TestStartTheDay: - def test_new_day_applies_the_weekly_bonus_after_the_reset( + def test_new_day_runs_the_week_transition_then_the_reset( self, log_file: Path, monkeypatch: pytest.MonkeyPatch ) -> None: calls: list[str] = [] @@ -63,22 +63,10 @@ class TestStartTheDay: calls.append("reset") return True - def bonus(_self: PokeLocker) -> None: - calls.append("bonus") - monkeypatch.setattr(_poke_credit, "process_week_transition", week) monkeypatch.setattr(_poke_credit, "reset_to_base_if_new_day", reset) - monkeypatch.setattr(PokeLocker, "_apply_weekly_shutdown_bonus", bonus) _poke_credit.fresh_locker().start_the_day() - assert calls == ["week", "reset", "bonus"] - - def test_same_day_does_not_apply_the_weekly_bonus( - self, log_file: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - bonus = MagicMock() - monkeypatch.setattr(PokeLocker, "_apply_weekly_shutdown_bonus", bonus) - _poke_credit.fresh_locker().start_the_day() - bonus.assert_not_called() + assert calls == ["week", "reset"] def test_credit_written_sets_the_entry_and_returns_the_result( self, log_file: Path, credit: MagicMock diff --git a/screen_locker/tests/test_screen_lock_coverage_part1.py b/screen_locker/tests/test_screen_lock_coverage_part1.py index c593473..729bd5e 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part1.py +++ b/screen_locker/tests/test_screen_lock_coverage_part1.py @@ -86,13 +86,13 @@ class TestCheckNonVerifyExitsExtras: ): locker._check_non_verify_exits() - def test_applies_weekly_bonus_on_fresh_day_reset( + def test_fresh_day_reset_layers_no_weekly_bonus( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: - """reset_to_base_if_new_day True → weekly shutdown bonus is applied once.""" + """A fresh-day reset is the whole shutdown: no weekly bonus on top (2026-10-10).""" locker = create_locker(mock_tk, tmp_path) object.__setattr__( locker, @@ -113,50 +113,6 @@ class TestCheckNonVerifyExitsExtras: "screen_locker._startup_checks.process_week_transition", return_value=[], ), - patch( - "screen_locker._sync_mixin.weekly_shutdown_bonus_hours", - return_value=2, - ), - patch("screen_locker._startup_checks.is_relaxed_day", return_value=False), - patch( - "screen_locker._startup_checks.has_weekly_minimum", return_value=True - ), - patch("screen_locker.screen_lock.sys.exit"), - ): - locker._check_non_verify_exits() - locker._adjust_shutdown_time_by.assert_called_once_with(120) - - def test_no_weekly_bonus_applied_when_not_a_fresh_day( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """reset_to_base_if_new_day False (same-day restart) → bonus not re-applied.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_scan_and_fill_week_runnerup", - MagicMock(return_value=0), - ) - object.__setattr__( - locker, - "_adjust_shutdown_time_by", - MagicMock(return_value=True), - ) - with ( - patch( - "screen_locker._startup_checks.reset_to_base_if_new_day", - return_value=False, - ), - patch( - "screen_locker._startup_checks.process_week_transition", - return_value=[], - ), - patch( - "screen_locker._sync_mixin.weekly_shutdown_bonus_hours", - return_value=2, - ), patch("screen_locker._startup_checks.is_relaxed_day", return_value=False), patch( "screen_locker._startup_checks.has_weekly_minimum", return_value=True diff --git a/screen_locker/tests/test_shutdown_ceiling_invariant.py b/screen_locker/tests/test_shutdown_ceiling_invariant.py new file mode 100644 index 0000000..f06c1af --- /dev/null +++ b/screen_locker/tests/test_shutdown_ceiling_invariant.py @@ -0,0 +1,206 @@ +"""Doing every task reaches the shutdown ceiling, on every day, in every state. + +The invariant behind dropping the weekly banked bonus (2026-10-10): the +ladder's floor is the ceiling minus every rung, so a day on which every earner +in ``earned_time.earners_for(day)`` is done to its full units (the tutor's +four blocks included) must shut down exactly at ``shutdown_ceiling_for(day)`` +-- never short of it, and never past it. Checked through screen-locker's own +paths, not a re-implementation: the daily reset's :func:`derive`, and the live +pass (first workout, further workouts, flat gates, counted gate blocks, grace +floor) over :class:`ShutdownMixin` with an in-memory config. + +Every gate maturity state is covered: ``first_credits`` omitted (0.5.0 +``penalty_from``), empty (every earner on its ``confirmed_on``), never paid +out / missing ledger (``None``), first paid today (penalty not started), +maturing (paid yesterday), mature, and a new tutor beside mature gates. +""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from typing import TYPE_CHECKING, Any +from unittest.mock import patch + +import earned_time +import pytest + +from screen_locker import _gate_bonus, _grace_floor, _shutdown +from screen_locker._earned import ceiling, extra_minutes, first_minutes, is_gate +from screen_locker._earned import registry as registry_for +from screen_locker._gate_bonus import apply_counted_bonus +from screen_locker._shutdown import ShutdownMixin +from screen_locker._shutdown_target import DayInputs, derive + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping + +_FIRST = date(2026, 10, 10) +_LAST = date(2026, 12, 31) +_DAYS = tuple(_FIRST + timedelta(days=n) for n in range((_LAST - _FIRST).days + 1)) +# adjust_shutdown_schedule.sh's RESTORE_CEILING: the helper clamps every +# --restore write to 23:00, so the double does too. +_HELPER_CEILING = 23 * 60 +_MORNING = 5 * 60 + +Credits = dict[str, date | None] | None + + +def _credits(fill: Callable[[date], date | None] | None) -> Callable[[date], Credits]: + """A per-day ``first_credits`` map giving every earner ``fill(day)``.""" + + def build(day: date) -> Credits: + if fill is None: + return None + return {e.name: fill(day) for e in registry_for(day)} + + return build + + +def _new_tutor_beside_mature(day: date) -> Credits: + mature = {e.name: date(2026, 8, 1) for e in registry_for(day)} + return {**mature, "automation": None} + + +_MATURITY: Mapping[str, Callable[[date], Credits]] = { + "penalty_from": _credits(None), + "confirmed_on": lambda _day: {}, + "never_paid": _credits(lambda _day: None), + "first_paid_today": _credits(lambda day: day), + "maturing": _credits(lambda day: day - timedelta(days=1)), + "mature": _credits(lambda _day: date(2026, 8, 1)), + "new_tutor": _new_tutor_beside_mature, +} +_CASES = [ + pytest.param( + name, rest, workouts, first, id=f"{name}-rest{rest}-w{workouts}-{first}" + ) + for name in _MATURITY + for rest in (False, True) + for workouts in (1, 2) + for first in ("none", "09:00", "22:30") +] + + +def _at(day: date, hhmm: str) -> float | None: + if hhmm == "none": + return None + hours, minutes = map(int, hhmm.split(":")) + return datetime.combine(day, time(hours, minutes)).astimezone().timestamp() + + +def _gates(day: date, *, done: bool) -> dict[str, int | bool | None]: + """Every gate's answer: all done (full units) or none.""" + return { + e.name: (e.max_units or True) if done else 0 + for e in registry_for(day) + if is_gate(e) + } + + +def _inputs(day: date, case: tuple[str, bool, int, str], *, done: bool) -> DayInputs: + name, rest, workouts, first = case + return DayInputs( + day=day, + flat=_gates(day, done=done), + workout_credits=workouts if done else 0, + rest_day=rest, + first_done=_at(day, first) if done else None, + first_credits=_MATURITY[name](day), + ) + + +@pytest.mark.parametrize(("maturity", "rest", "workouts", "first"), _CASES) +def test_the_reset_lands_every_all_done_day_on_the_ceiling( + maturity: str, rest: bool, workouts: int, first: str +) -> None: + """``derive`` (the daily reset's formula) == ceiling on every day.""" + case = (maturity, rest, workouts, first) + misses = [ + (day.isoformat(), got, ceiling(day)) + for day in _DAYS + if (got := derive(_inputs(day, case, done=True)).minutes) != ceiling(day) + ] + assert misses == [] + + +class _Host(ShutdownMixin): + """The real add / absorb / floor paths over an in-memory config.""" + + def __init__(self, minutes: int) -> None: + self.config = (minutes, minutes, _MORNING) + + def _read_shutdown_config(self) -> tuple[int, int, int] | None: + return self.config + + def _write_shutdown_config( + self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = False + ) -> bool: + assert restore + self.config = ( + min(_HELPER_CEILING, mon_wed), + min(_HELPER_CEILING, thu_sun), + morning_end, + ) + return True + + +def _live_pass(day: date, case: tuple[str, bool, int, str]) -> int: + """Reset with nothing done, then every credit as the live pass applies it.""" + _, rest, workouts, first = case + host = _Host(derive(_inputs(day, case, done=False)).minutes) + first_done = _at(day, first) + if first_done is not None: + host._apply_grace_floor(None, first_done=first_done) + # A rest day's first unit is already in the reset; a real workout on it + # is a further unit (_workout_credit's rest_paid rule). + further = workouts if rest else workouts - 1 + if not rest: + host._adjust_shutdown_time_later() + extra = extra_minutes(earned_time.WORKOUT, day) + for _ in range(further if extra else 0): + host._adjust_shutdown_time_by(extra) + state: dict[str, Any] = {} + for gate in (e for e in registry_for(day) if is_gate(e)): + if gate.kind == "flat": + host._adjust_shutdown_time_by(first_minutes(gate, day)) + continue + for units in range(1, (gate.max_units or 1) + 1): + with patch.object(_gate_bonus, "earned_units", return_value=units): + apply_counted_bonus( + state, host._adjust_shutdown_time_by, gate, day.isoformat() + ) + return host.config[1] + + +@pytest.fixture +def frozen_day() -> Iterator[Callable[[date], Any]]: + """Point every live path's ``today`` at the simulated day.""" + + def at_day(day: date) -> Any: + iso = day.isoformat() + return patch.multiple(_shutdown, today_str=lambda: iso), patch.multiple( + _grace_floor, today_str=lambda: iso, sick_on=lambda _d: False + ) + + return at_day + + +@pytest.mark.parametrize(("maturity", "rest", "workouts", "first"), _CASES) +def test_the_live_pass_lands_every_all_done_day_on_the_ceiling( + maturity: str, + rest: bool, + workouts: int, + first: str, + frozen_day: Callable[[date], Any], +) -> None: + """Reset + every live credit, in registry order == ceiling on every day.""" + case = (maturity, rest, workouts, first) + misses = [] + for day in _DAYS: + shutdown_patch, grace_patch = frozen_day(day) + with shutdown_patch, grace_patch: + got = _live_pass(day, case) + _grace_floor.GRACE_STATE_FILE.unlink(missing_ok=True) + if got != ceiling(day): + misses.append((day.isoformat(), got, ceiling(day))) + assert misses == [] diff --git a/screen_locker/tests/test_status.py b/screen_locker/tests/test_status.py index 0a6d00a..e35df95 100644 --- a/screen_locker/tests/test_status.py +++ b/screen_locker/tests/test_status.py @@ -161,15 +161,14 @@ class TestRunStatusNormal: out = capsys.readouterr().out assert "Shutdown tonight" not in out - def test_shutdown_bonus_and_streak_shown( + def test_streak_shown_and_no_weekly_bonus_line( self, tmp_path: Path, capsys: pytest.CaptureFixture ) -> None: - """bonus_hours=3, streak=2, eb_ext=True → shown in output.""" + """streak=2, eb_ext=True → shown; the dropped weekly bonus is not.""" eb_file = tmp_path / "eb.json" locker = _make_locker(tmp_path / "log.json", n_filled=0) with ( patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file), - patch("screen_locker._status.weekly_shutdown_bonus_hours", return_value=3), patch("screen_locker._status.current_streak", return_value=2), patch("screen_locker._status.has_extended_early_bird", return_value=True), patch("screen_locker._status.count_weekly_workouts", return_value=0), @@ -177,7 +176,7 @@ class TestRunStatusNormal: ): run_status(locker) out = capsys.readouterr().out - assert "Shutdown bonus (this wk): 3h" in out + assert "Shutdown bonus" not in out assert "Streak (5+ wks) : 2" in out assert "Yes — until 09:00" in out diff --git a/screen_locker/tests/test_status_data_part2.py b/screen_locker/tests/test_status_data_part2.py index 5c2575d..50c286e 100644 --- a/screen_locker/tests/test_status_data_part2.py +++ b/screen_locker/tests/test_status_data_part2.py @@ -102,10 +102,10 @@ class TestGatherStatus: assert snap.shutdown.tonight == (1320, 1380, 300) - def test_bonus_streak_and_extended_early_bird_reflected( + def test_streak_and_early_bird_reflected_legacy_bonus_ignored( self, tmp_path: Path ) -> None: - """Bonus streak and extended early bird reflected.""" + """Streak + early bird reflected; a legacy banked weekly bonus adds nothing.""" files = _files(tmp_path) files["extra_benefits_file"].write_text( json.dumps( @@ -121,10 +121,9 @@ class TestGatherStatus: ): snap = gather_status(**files, now=_FRIDAY_NOON_UTC) - assert snap.bonus_hours_this_week == 3 assert snap.streak == 2 assert snap.early_bird_extended is True - assert snap.shutdown.rest_of_week[0].minutes == 23 * 60 # 20h base + 3h bonus + assert snap.shutdown.rest_of_week[0].minutes == 20 * 60 # base, no bonus def test_next_week_preview_is_speculative_rest_of_week_is_not( self, tmp_path: Path diff --git a/scripts/finish_2026_08_24.sh b/scripts/finish_2026_08_24.sh index 54f9eb7..e6c725a 100755 --- a/scripts/finish_2026_08_24.sh +++ b/scripts/finish_2026_08_24.sh @@ -51,32 +51,29 @@ main() { validate_requirements cd "$SCRIPT_DIR" - step "1/7 Test suite (must be green before touching live state)" + step "1/6 Test suite (must be green before touching live state)" "$PY" -m pytest screen_locker/tests/ -q --no-cov - step "2/7 Lint + type gate" + step "2/6 Lint + type gate" "$PY" -m pre_commit run --all-files - step "3/7 Sync: recover Firebase, then ingest today's workout" + step "3/6 Sync: recover Firebase, then ingest today's workout" # --sync-only never locks, so this is safe to run while disarmed. ./run.sh --sync-only - step "4/7 Did $TARGET_DATE actually land in the log?" + step "4/6 Did $TARGET_DATE actually land in the log?" # Gate, don't warn: if the credit is not on disk there is nothing to # revoke a sick day against, and re-arming here would lock you out again. "$PY" scripts/verify_workout_credited.py --date "$TARGET_DATE" - step "5/7 Restore shutdown hours + grant the bonus" - "$PY" scripts/restore_and_bonus.py --date "$TARGET_DATE" - - step "6/7 Revoke the sick days that this workout disproves" + step "5/6 Revoke the sick days that this workout disproves" "$PY" scripts/revoke_sick_day.py --date "$TARGET_DATE" --date 2026-06-12 if ((ARM)); then - step "7/7 Re-arm enforcement" + step "6/6 Re-arm enforcement" ./arm.sh --rearm else - step "7/7 Skipped re-arm (--no-arm); enforcement stays OFF" + step "6/6 Skipped re-arm (--no-arm); enforcement stays OFF" echo "Re-arm later with: ./arm.sh --rearm" fi diff --git a/scripts/preview_shutdown.py b/scripts/preview_shutdown.py index 04cca3f..79c87e8 100755 --- a/scripts/preview_shutdown.py +++ b/scripts/preview_shutdown.py @@ -30,10 +30,9 @@ import tempfile import earned_time from screen_locker import _earned, _grace_floor, _shutdown -from screen_locker._constants import EXTRA_BENEFITS_FILE, SHUTDOWN_CONFIG_FILE +from screen_locker._constants import SHUTDOWN_CONFIG_FILE from screen_locker._day import today_str from screen_locker._earned import hhmm -from screen_locker._extra_benefits import weekly_shutdown_bonus_hours from screen_locker._rest_day import declare, is_rest_day from screen_locker._shutdown import read_shutdown_config from screen_locker._shutdown_target import ( @@ -44,7 +43,6 @@ from screen_locker._shutdown_target import ( gather, ) from scripts._preview_sim import ( - HELPER_CEILING, SimRunnerUp, SimShutdown, sick_history, @@ -166,12 +164,7 @@ def main() -> int: ) target = gather(today, _LOG) _report("today, real ledgers", target) - weekly = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE) * 60 - _say( - f" + banked weekly bonus {weekly} min (layered after the reset, " - f"absorbs the grace lift) -> expected config " - f"{hhmm(min(HELPER_CEILING, max(target.earned + weekly, target.minutes)))}" - ) + _say(f" expected config after the next reset: {hhmm(target.minutes)}") return 0 day = date.fromisoformat(args.day) _say(f" ladder in force on {day}: {ladder(day) if ladder else False}") diff --git a/scripts/restore_and_bonus.py b/scripts/restore_and_bonus.py deleted file mode 100755 index cc64d41..0000000 --- a/scripts/restore_and_bonus.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python3 -"""Undo a sick day's shutdown penalty and grant the agreed bonus hours. - -Two separate corrections, in this order: - -1. **Restore the baseline.** Taking a sick day pulled the shutdown hours down - to 21/21 and stashed the real values in ``sick_day_state.json`` as - ``original_mon_wed_hour`` / ``original_thu_sun_hour`` (both 22). Restoring - comes first, because otherwise a "bonus" merely returns you to baseline and - the compensation is silently swallowed. - -2. **Bank the bonus.** Written into ``extra_benefits_state.json`` under - ``weekly_shutdown_bonus_hours[]`` -- the same key - ``_extra_benefits.weekly_shutdown_bonus_hours()`` reads and - ``_apply_weekly_shutdown_bonus`` applies every day of the week. The locker - used this map for streak rewards until 2026-W41; since then this script is - its only writer. - -Two bonus hours by default: one for each day a completed workout was not -counted (2026-06-12 and 2026-08-24). - -Usage: - python3 scripts/restore_and_bonus.py --date 2026-08-24 - python3 scripts/restore_and_bonus.py --date 2026-08-24 --bonus-hours 3 -""" - -from __future__ import annotations - -import argparse -from datetime import datetime -import json -import logging -from pathlib import Path -import sys - -REPO_ROOT = Path(__file__).resolve().parent.parent -SICK_DAY_STATE_FILE = REPO_ROOT / "screen_locker" / "sick_day_state.json" -EXTRA_BENEFITS_FILE = REPO_ROOT / "screen_locker" / "extra_benefits_state.json" - -DEFAULT_BONUS_HOURS = 2 - -# Failures go to the log (stderr) as well as stdout: this script runs as one -# step of finish_2026_08_24.sh, where a bare stdout line scrolls past in a wall -# of other output, and it mutates benefit state — the exact case where a quiet -# failure looks like success. -_logger = logging.getLogger(__name__) - - -def _report(message: str) -> None: - """Write one line to stdout (matches the other scripts in this directory).""" - sys.stdout.write(f"{message}\n") - - -def _iso_week(date_str: str) -> str: - """Return ``YYYY-Www`` for ``date_str``, matching ``_current_iso_week``.""" - parsed = datetime.strptime(date_str, "%Y-%m-%d").astimezone() - year, week, _ = parsed.isocalendar() - return f"{year}-W{week:02d}" - - -def _load(path: Path) -> dict: - """Return ``path``'s JSON object, or an empty dict when absent.""" - if not path.is_file(): - return {} - try: - return json.loads(path.read_text()) - except (OSError, json.JSONDecodeError) as exc: - _logger.warning("Could not read %s: %s — treating as empty", path, exc) - _report(f"WARN: could not read {path}: {exc} — treating as empty") - return {} - - -def restore_hours(state: dict) -> tuple[int, int] | None: - """Return the (mon_wed, thu_sun) hours the sick day displaced, if any.""" - mon_wed = state.get("original_mon_wed_hour") - thu_sun = state.get("original_thu_sun_hour") - if mon_wed is None or thu_sun is None: - return None - return int(mon_wed), int(thu_sun) - - -def main() -> int: - """Restore the pre-sick-day shutdown hours and bank the bonus.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--date", required=True, help="the revoked sick day") - parser.add_argument("--bonus-hours", type=int, default=DEFAULT_BONUS_HOURS) - parser.add_argument("--sick-day-state", default=str(SICK_DAY_STATE_FILE)) - parser.add_argument("--benefits-file", default=str(EXTRA_BENEFITS_FILE)) - args = parser.parse_args() - - sick_state = _load(Path(args.sick_day_state)) - restored = restore_hours(sick_state) - if restored is None: - _report( - "No original shutdown hours recorded — the baseline is already " - "whatever the daily reset produces, so nothing to restore." - ) - else: - mon_wed, thu_sun = restored - _report( - f"Baseline to restore: Mon-Wed={mon_wed}:00, Thu-Sun={thu_sun}:00 " - "(the daily reset re-applies this on the next run)" - ) - - benefits = _load(Path(args.benefits_file)) - week = _iso_week(args.date) - bonus_map = dict(benefits.get("weekly_shutdown_bonus_hours", {})) - existing = int(bonus_map.get(week, 0)) - - # Idempotence guard. Banking is `existing + bonus`, so without a durable - # record of which dates were already compensated a re-run silently grants - # the hours a second time. The bonus map alone cannot answer that question: - # a week's total mixes earned streak hours with these corrections, so no - # arithmetic on it can tell "already applied" from "earned that much". - granted = list(benefits.get("shutdown_bonus_granted_for", [])) - if args.date in granted: - _report( - f"Bonus for {args.date} was already banked into {week} " - f"(currently {existing}h) — not granting it twice." - ) - return 0 - - granted.append(args.date) - benefits["shutdown_bonus_granted_for"] = granted - bonus_map[week] = existing + args.bonus_hours - benefits["weekly_shutdown_bonus_hours"] = bonus_map - - try: - Path(args.benefits_file).write_text(json.dumps(benefits, indent=2) + "\n") - except OSError as exc: - _logger.exception( - "Could not write %s — the bonus was NOT banked", - args.benefits_file, - ) - _report(f"FAIL: could not write {args.benefits_file}: {exc}") - return 1 - - _report( - f"Banked +{args.bonus_hours}h shutdown bonus for {week} " - f"(was {existing}h, now {bonus_map[week]}h) — applied every day of " - "that week on top of the restored baseline." - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())