drop the weekly shutdown bonus entirely; every bonus stops at the shutdown ceiling
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 (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-10 08:59:50 +02:00
parent 87c50825f9
commit fc5453495c
23 changed files with 290 additions and 392 deletions

View File

@ -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: throws almost all of it away. Verified 2026-08-16:
- `StatusSnapshot` (`screen_locker/_status_types.py` ~:88-101) carries populated - `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.** `_status_data.py` ~:210-214. **Grep confirms no renderer reads them.**
- `_extra_benefits.py` `process_week_transition()` (~:82, reward strings built at - `_extra_benefits.py` `process_week_transition()` (~:82, reward strings built at
~:130-139) produces fully-formed celebratory sentences — and ~:130-139) produces fully-formed celebratory sentences — and
@ -81,8 +81,7 @@ Primary:
- `screen_locker/_status_types.py` — `StatusSnapshot` (~:88-101), the fields you - `screen_locker/_status_types.py` — `StatusSnapshot` (~:88-101), the fields you
are rendering. Read-only for this task. are rendering. Read-only for this task.
- `screen_locker/_extra_benefits.py` (179 lines) — `process_week_transition()` - `screen_locker/_extra_benefits.py` (179 lines) — `process_week_transition()`
and its reward strings; `current_streak()` ~:154, and its reward strings; `current_streak()`, `has_extended_early_bird()`.
`weekly_shutdown_bonus_hours()` ~:159, `has_extended_early_bird()` ~:171.
- `screen_locker/_startup_checks.py` ~:57-58 — where the reward strings are - `screen_locker/_startup_checks.py` ~:57-58 — where the reward strings are
currently discarded into a log call. currently discarded into a log call.
- `screen_locker/_unlock_view.py` (72 lines, lots of headroom) — the existing - `screen_locker/_unlock_view.py` (72 lines, lots of headroom) — the existing
@ -91,8 +90,8 @@ Primary:
## must ## must
- **Render the already-populated fields.** Add `_section_streak` to - **Render the already-populated fields.** Add `_section_streak` to
`_status_sections.py` showing the weekly streak, this week's bonus hours, and `_status_sections.py` showing the weekly streak and the extended-early-bird
the extended-early-bird state, and call it from `status_view.py`'s `render()`. state, and call it from `status_view.py`'s `render()`.
Zero new state, zero conftest change, zero new dependency. Zero new state, zero conftest change, zero new dependency.
- **Stop discarding the reward strings.** `process_week_transition()` returns - **Stop discarding the reward strings.** `process_week_transition()` returns
human-readable celebratory sentences that currently only reach the log. Surface 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 {phone_verified, runnerup_verified, manual_workout}`). Do not round, pad, or
"encourage" the number. "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: 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 > that mechanic works against the goal of maximizing weekly workouts, so it was
> removed in favor of a shutdown-time-only reward" > removed."
> `_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."
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 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. 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 - 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 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 unlock trains the wrong association. This repo stays silent. (Sound is
@ -141,7 +143,7 @@ Primary:
## done ## done
1. Opening the status window (tray icon → click, or `screen-locker-status`) shows 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. from `extra_benefits_state.json`, not placeholders.
2. A week-transition milestone reaches the UI instead of only `_logger.info`. 2. A week-transition milestone reaches the UI instead of only `_logger.info`.
3. `cd ~/src/screen-locker && python -m pytest` passes. 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 screen-locker-status # the Tk window: confirm they render
``` ```
Paste both outputs. The CLI already prints `streak`/`bonus_hours` (`_status.py` Paste both outputs. The CLI already prints the streak and early-bird state
~:138-141), so it is your ground truth: the window must show the **same numbers**. (`_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. If they disagree, the renderer is wrong, not the data.
Current real state for comparison: `screen_locker/extra_benefits_state.json` and 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 - All persistence is **JSON files, no sqlite**; paths declared in
`screen_locker/_constants.py`. The streak store is `screen_locker/_constants.py`. The streak store is
`screen_locker/extra_benefits_state.json` (`consecutive_5plus_weeks`, `screen_locker/extra_benefits_state.json` (`consecutive_5plus_weeks`,
`last_processed_iso_week`, `weekly_shutdown_bonus_hours`, `last_processed_iso_week`, `extended_early_bird_iso_weeks`; a legacy
`extended_early_bird_iso_weeks`). `weekly_shutdown_bonus_hours` map is ignored and dropped on the next write).
- Thresholds live in `_extra_benefits.py`: `_MILESTONE_INTERVAL = 4` (4-week - The threshold lives in `_extra_benefits.py`: `_BONUS_THRESHOLD = 5` (workouts
milestone), `_BONUS_THRESHOLD = 5` (workouts per week). 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 - **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; 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 for a self-restriction tool, requiring a code edit to disable is arguably

View File

@ -4,12 +4,11 @@ Tracks:
- Consecutive weeks with 5+ workouts (streak counter). - Consecutive weeks with 5+ workouts (streak counter).
- ISO weeks in which the early-bird window is extended to 09:00. - 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 There is no weekly shutdown-time bonus (dropped entirely 2026-10-10, hours
(dropped 2026-10-10; 2026-W41 was the last week to receive any). The already banked for 2026-W41 included): every shutdown minute comes from the
``weekly_shutdown_bonus_hours`` map is still *read* by ``earned_time`` ladder and stops at its daily ceiling. A legacy
:func:`weekly_shutdown_bonus_hours`, so hours already banked for the current ``weekly_shutdown_bonus_hours`` map in an old state file is ignored, and
week keep applying, and ``scripts/restore_and_bonus.py`` can still bank a dropped at the next week transition's write.
one-off compensation into it — but the week transition never writes it.
State is persisted in ``extra_benefits_state.json`` next to this file. State is persisted in ``extra_benefits_state.json`` next to this file.
""" """
@ -29,6 +28,8 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_BONUS_THRESHOLD = 5 # workouts/week required to extend the streak + early-bird _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]: 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 streak = 0
# Merge onto the loaded state rather than rebuilding it from these three # Merge onto the loaded state rather than rebuilding it from these three
# keys: the legacy ``weekly_shutdown_bonus_hours`` map (still read, and # keys: sibling fields written by the recovery scripts (e.g.
# still written by restore_and_bonus.py) and sibling fields written by the # ``shutdown_bonus_granted_for``) live in this file too, and rebuilding
# recovery scripts (e.g. ``shutdown_bonus_granted_for``, the guard that # would drop them at the next weekly rollover. Only the dead weekly-bonus
# stops a compensation bonus being granted twice) live in this file too, # map is dropped, so no reader can mistake it for live state.
# and rebuilding would drop them at the next weekly rollover — losing this
# week's banked hours and silently re-arming a double-grant.
updated = dict(state) updated = dict(state)
updated.pop(_LEGACY_BONUS_KEY, None)
updated.update( updated.update(
{ {
"consecutive_5plus_weeks": streak, "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)) 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: 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).""" """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() now = today if today is not None else datetime.now(tz=UTC).astimezone()

View File

@ -80,21 +80,17 @@ class PokeLocker(ScreenLocker):
"""Run the new-day reset the locker's startup runs, in the same order. """Run the new-day reset the locker's startup runs, in the same order.
Mirrors ``StartupChecksMixin._check_non_verify_exits``, not Mirrors ``StartupChecksMixin._check_non_verify_exits``, not
``sync_now`` (which never resets): the week transition must be ``sync_now`` (which never resets): the week transition (streak,
processed BEFORE the reset, and any banked weekly bonus layered on only early-bird window) first, then the daily reset.
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.
""" """
for reward in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE): for reward in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE):
_logger.info("Weekly reward: %s", reward) _logger.info("Weekly reward: %s", reward)
if reset_to_base_if_new_day( reset_to_base_if_new_day(
SHUTDOWN_BASE_FILE, SHUTDOWN_BASE_FILE,
self, self,
sick_day_state_file=SICK_DAY_STATE_FILE, sick_day_state_file=SICK_DAY_STATE_FILE,
log_file=self.log_file, log_file=self.log_file,
): )
self._apply_weekly_shutdown_bonus()
def credit_written( def credit_written(
self, entry: dict[str, str], prior_entries: list[dict[str, object]] self, entry: dict[str, str], prior_entries: list[dict[str, object]]

View File

@ -105,19 +105,16 @@ class StartupChecksMixin(SyncMixin):
self._record_skip("scheduled_skip_day", "Today is a scheduled skip day.") self._record_skip("scheduled_skip_day", "Today is a scheduled skip day.")
return return
# Award streak / EB-extension rewards from last week before the daily # Award streak / EB-extension rewards from last week before the daily
# reset. The transition no longer banks shutdown hours; any already # reset. No week carries a shutdown bonus any more (2026-10-10).
# banked for this week are layered on by _apply_weekly_shutdown_bonus.
for reward_msg in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE): for reward_msg in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE):
_logger.info("Weekly reward: %s", reward_msg) _logger.info("Weekly reward: %s", reward_msg)
# Reset shutdown config to base (19:00) at the start of each new day, # Reset shutdown config to the day's base plus what today already earned.
# then layer this week's banked bonus (if any) back on the fresh base. reset_to_base_if_new_day(
if reset_to_base_if_new_day(
SHUTDOWN_BASE_FILE, SHUTDOWN_BASE_FILE,
self, self,
sick_day_state_file=SICK_DAY_STATE_FILE, sick_day_state_file=SICK_DAY_STATE_FILE,
log_file=self.log_file, log_file=self.log_file,
): )
self._apply_weekly_shutdown_bonus()
# A LeetCode solve or a reading credit usually lands hours after the # 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. # reset; this 5-minute tick turns it into the extra hour the same day.
apply_flat_bonuses_if_new(SHUTDOWN_BASE_FILE, self) 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 # Only remaining same-day skip: genuine extreme heat. Sick days go
# through the justification flow instead; there is no banked # through the justification flow instead; there is no banked
# "skip a workout" credit — that mechanic works against the goal of # "skip a workout" credit — that mechanic works against the goal of
# maximizing weekly workouts, so it was removed in favor of a # maximizing weekly workouts, so it was removed.
# shutdown-time-only reward (see _apply_weekly_shutdown_bonus).
# A logged workout outranks every rung above, whatever their order. # A logged workout outranks every rung above, whatever their order.
if refuse_to_lock_over_logged_workout(self.has_logged_today, self._record_skip): if refuse_to_lock_over_logged_workout(self.has_logged_today, self._record_skip):
return return

View File

@ -14,7 +14,6 @@ from screen_locker._earned import hhmm
from screen_locker._extra_benefits import ( from screen_locker._extra_benefits import (
current_streak, current_streak,
has_extended_early_bird, has_extended_early_bird,
weekly_shutdown_bonus_hours,
) )
from screen_locker._log_io import load_workout_log from screen_locker._log_io import load_workout_log
from screen_locker._sick_tracker import load_history from screen_locker._sick_tracker import load_history
@ -41,7 +40,7 @@ def _load_extra_benefits() -> dict:
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
_logger.warning( _logger.warning(
"Could not read extra-benefits state from %s: %s — the status view " "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, EXTRA_BENEFITS_FILE,
exc, exc,
) )
@ -116,7 +115,6 @@ def run_status(locker: ScreenLocker) -> None:
print() print()
# Extra benefits summary # Extra benefits summary
bonus_hours = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE)
streak = current_streak(EXTRA_BENEFITS_FILE) streak = current_streak(EXTRA_BENEFITS_FILE)
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE) eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
eb_str = "Yes — until 09:00" if eb_ext else "No" eb_str = "Yes — until 09:00" if eb_ext else "No"
@ -138,7 +136,6 @@ def run_status(locker: ScreenLocker) -> None:
else: else:
heat_str = "0" heat_str = "0"
print(f" Shutdown bonus (this wk): {bonus_hours}h")
print(f" Streak (5+ wks) : {streak}") print(f" Streak (5+ wks) : {streak}")
print(f" Early-bird extended : {eb_str}") print(f" Early-bird extended : {eb_str}")
print(f" Heat skips (month) : {heat_str}") print(f" Heat skips (month) : {heat_str}")

View File

@ -54,7 +54,6 @@ from screen_locker._constants import (
from screen_locker._extra_benefits import ( from screen_locker._extra_benefits import (
current_streak, current_streak,
has_extended_early_bird, has_extended_early_bird,
weekly_shutdown_bonus_hours,
) )
from screen_locker._log_io import load_workout_log from screen_locker._log_io import load_workout_log
from screen_locker._morning_session import has_workout_skip_today from screen_locker._morning_session import has_workout_skip_today
@ -203,14 +202,10 @@ def gather_status(
return StatusSnapshot( return StatusSnapshot(
today=days[-1], today=days[-1],
week=week, week=week,
bonus_hours_this_week=weekly_shutdown_bonus_hours(
extra_benefits_file, today=today_local
),
streak=current_streak(extra_benefits_file), streak=current_streak(extra_benefits_file),
early_bird_extended=early_bird_extended, early_bird_extended=early_bird_extended,
shutdown=_shutdown_projection( shutdown=_shutdown_projection(
shutdown_config_file=shutdown_config_file, shutdown_config_file=shutdown_config_file,
extra_benefits_file=extra_benefits_file,
today_local=today_local, today_local=today_local,
), ),
lock_explanation=lock_explanation, lock_explanation=lock_explanation,

View File

@ -14,7 +14,6 @@ from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from screen_locker._earned import hhmm 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 import read_shutdown_config
from screen_locker._shutdown_base import base_minutes from screen_locker._shutdown_base import base_minutes
from screen_locker._status_types import ShutdownProjection, ShutdownProjectionDay from screen_locker._status_types import ShutdownProjection, ShutdownProjectionDay
@ -31,14 +30,9 @@ _RELAXED_DAY_EXPLANATION = (
) )
_WEEKDAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") _WEEKDAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
_MON_WED_WEEKDAYS = frozenset({0, 1, 2}) _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( def _week_rows(monday: date, *, speculative: bool) -> tuple[ShutdownProjectionDay, ...]:
monday: date, bonus: int, *, speculative: bool
) -> tuple[ShutdownProjectionDay, ...]:
"""Build 7 labeled rows, Mon-Sun from ``monday``, each on its own day's base. """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 Each row asks ``base_minutes`` for its own date, so a registry cut dated
@ -47,7 +41,7 @@ def _week_rows(
return tuple( return tuple(
ShutdownProjectionDay( ShutdownProjectionDay(
label=label, label=label,
minutes=base_minutes(monday + timedelta(days=weekday)) + bonus, minutes=base_minutes(monday + timedelta(days=weekday)),
speculative=speculative, speculative=speculative,
) )
for weekday, label in enumerate(_WEEKDAY_LABELS) for weekday, label in enumerate(_WEEKDAY_LABELS)
@ -57,23 +51,17 @@ def _week_rows(
def _shutdown_projection( def _shutdown_projection(
*, *,
shutdown_config_file: Path, shutdown_config_file: Path,
extra_benefits_file: Path,
today_local: datetime, today_local: datetime,
) -> ShutdownProjection: ) -> ShutdownProjection:
"""Build the shutdown-time projection: tonight, rest of week, next week. """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 Every row is the plain per-day base: no week carries a banked shutdown
shutdown hours for the week after (only the early-bird extension). bonus (dropped 2026-10-10).
""" """
tonight = read_shutdown_config(shutdown_config_file) 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()) monday = today_local.date() - timedelta(days=today_local.weekday())
rest_of_week = _week_rows(monday, bonus, speculative=False) rest_of_week = _week_rows(monday, speculative=False)
next_week_preview = _week_rows(monday + timedelta(weeks=1), speculative=True)
next_week_preview = _week_rows(monday + timedelta(weeks=1), 0, speculative=True)
return ShutdownProjection( return ShutdownProjection(
tonight=tonight, tonight=tonight,

View File

@ -94,7 +94,6 @@ class StatusSnapshot:
today: DayStatus today: DayStatus
week: WeeklySummary week: WeeklySummary
bonus_hours_this_week: int
streak: int streak: int
early_bird_extended: bool early_bird_extended: bool
shutdown: ShutdownProjection shutdown: ShutdownProjection

View File

@ -10,10 +10,9 @@ from __future__ import annotations
import logging 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._earned import span
from screen_locker._earner_units import sync_path_unit 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_push import push_pc_workouts
from screen_locker._manual_sync import ingest_manual_records from screen_locker._manual_sync import ingest_manual_records
from screen_locker._session_sync import ingest_session_records from screen_locker._session_sync import ingest_session_records
@ -27,9 +26,6 @@ __all__ = ["SyncMixin"]
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# A banked weekly bonus is in whole hours; the schedule takes minutes.
_MINUTES_PER_HOUR = 60
class SyncMixin: class SyncMixin:
"""Publish, ingest and credit manual workouts synced between devices.""" """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 "Auto-filled %d RunnerUp workout(s) from TCX exports.", n_filled
) )
self.workout_data = {} 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)

View File

@ -196,7 +196,7 @@ def count_weekly_workouts(
recorded twice is credited once. Manual workouts are rate-limited recorded twice is credited once. Manual workouts are rate-limited
separately by the manual-workout budget separately by the manual-workout budget
(:mod:`screen_locker._manual_workout`), not by a collapse here. Feeds both (: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: Args:
log_file: Path to ``log.json``. log_file: Path to ``log.json``.

View File

@ -27,7 +27,7 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
( (
"_constants.EXTRA_BENEFITS_FILE", "_constants.EXTRA_BENEFITS_FILE",
"_startup_checks.EXTRA_BENEFITS_FILE", "_startup_checks.EXTRA_BENEFITS_FILE",
"_sync_mixin.EXTRA_BENEFITS_FILE", "_poke_credit.EXTRA_BENEFITS_FILE",
"_status.EXTRA_BENEFITS_FILE", "_status.EXTRA_BENEFITS_FILE",
), ),
), ),

View File

@ -134,7 +134,6 @@ def _snapshot(**overrides: object) -> StatusSnapshot:
default = StatusSnapshot( default = StatusSnapshot(
today=_day(), today=_day(),
week=_week(), week=_week(),
bonus_hours_this_week=0,
streak=0, streak=0,
early_bird_extended=False, early_bird_extended=False,
shutdown=_shutdown(), shutdown=_shutdown(),

View File

@ -29,8 +29,8 @@ class TestLoadState:
def test_returns_parsed_state_when_file_valid(self, tmp_path: Path) -> None: def test_returns_parsed_state_when_file_valid(self, tmp_path: Path) -> None:
"""Valid JSON file returns the parsed dict.""" """Valid JSON file returns the parsed dict."""
f = tmp_path / "state.json" f = tmp_path / "state.json"
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2026-W01": 3}})) f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2026-W01"]}))
assert _load_state(f) == {"weekly_shutdown_bonus_hours": {"2026-W01": 3}} assert _load_state(f) == {"extended_early_bird_iso_weeks": ["2026-W01"]}
def test_returns_empty_on_oserror(self) -> None: def test_returns_empty_on_oserror(self) -> None:
"""OSError during read is caught and returns empty dict (lines 33-34).""" """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: def test_saves_state_to_file(self, tmp_path: Path) -> None:
"""Valid path writes JSON content (lines 39-41).""" """Valid path writes JSON content (lines 39-41)."""
f = tmp_path / "state.json" f = tmp_path / "state.json"
_save_state(f, {"weekly_shutdown_bonus_hours": {"2026-W01": 2}}) _save_state(f, {"extended_early_bird_iso_weeks": ["2026-W02"]})
assert json.loads(f.read_text())["weekly_shutdown_bonus_hours"] == { assert json.loads(f.read_text())["extended_early_bird_iso_weeks"] == [
"2026-W01": 2 "2026-W02"
} ]
def test_logs_warning_on_oserror(self) -> None: def test_logs_warning_on_oserror(self) -> None:
"""OSError during write is caught as warning, does not raise (lines 42-43).""" """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, "last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 0, "consecutive_5plus_weeks": 0,
"weekly_shutdown_bonus_hours": {},
"extended_early_bird_iso_weeks": [], "extended_early_bird_iso_weeks": [],
} }
) )
@ -113,10 +112,10 @@ class TestProcessWeekTransition:
assert not any("shutdown bonus" in r for r in rewards) assert not any("shutdown bonus" in r for r in rewards)
state = json.loads(f.read_text()) state = json.loads(f.read_text())
assert state["consecutive_5plus_weeks"] == 1 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: 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 = tmp_path / "state.json"
f.write_text( f.write_text(
json.dumps( json.dumps(
@ -137,7 +136,7 @@ class TestProcessWeekTransition:
assert "4-week streak" in rewards[0] assert "4-week streak" in rewards[0]
state = json.loads(f.read_text()) state = json.loads(f.read_text())
assert state["consecutive_5plus_weeks"] == 4 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: 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).""" """5+ workouts mark current ISO week as extended EB (line 91-92)."""

View File

@ -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 from __future__ import annotations
@ -10,37 +10,12 @@ from unittest.mock import patch
from screen_locker._extra_benefits import ( from screen_locker._extra_benefits import (
has_extended_early_bird, has_extended_early_bird,
process_week_transition, process_week_transition,
weekly_shutdown_bonus_hours,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path 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): class _FrozenDatetime(datetime):
"""``datetime`` whose ``now`` is pinned to Monday 2026-10-12 12:00 UTC.""" """``datetime`` whose ``now`` is pinned to Monday 2026-10-12 12:00 UTC."""
@ -52,7 +27,7 @@ class _FrozenDatetime(datetime):
class TestBonusDroppedAtW42: 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]] = { _STATE: ClassVar[dict[str, object]] = {
"consecutive_5plus_weeks": 3, "consecutive_5plus_weeks": 3,
@ -81,23 +56,16 @@ class TestBonusDroppedAtW42:
) )
] ]
state = json.loads(f.read_text()) state = json.loads(f.read_text())
# Sibling keys (the compensation guard) survive; only the dead map goes.
assert state == { assert state == {
**self._STATE,
"consecutive_5plus_weeks": 4, "consecutive_5plus_weeks": 4,
"last_processed_iso_week": "2026-W42", "last_processed_iso_week": "2026-W42",
"extended_early_bird_iso_weeks": ["2026-W41", "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) 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 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: class TestHasExtendedEarlyBird:
"""Tests for has_extended_early_bird.""" """Tests for has_extended_early_bird."""

View File

@ -58,7 +58,6 @@ def _snapshot() -> StatusSnapshot:
week=WeeklySummary( week=WeeklySummary(
days=(day,), counted_count=2, minimum=4, remaining=2, extra=0 days=(day,), counted_count=2, minimum=4, remaining=2, extra=0
), ),
bonus_hours_this_week=0,
streak=1, streak=1,
early_bird_extended=False, early_bird_extended=False,
shutdown=ShutdownProjection( shutdown=ShutdownProjection(

View File

@ -50,7 +50,7 @@ def credit(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
class TestStartTheDay: 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 self, log_file: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
calls: list[str] = [] calls: list[str] = []
@ -63,22 +63,10 @@ class TestStartTheDay:
calls.append("reset") calls.append("reset")
return True return True
def bonus(_self: PokeLocker) -> None:
calls.append("bonus")
monkeypatch.setattr(_poke_credit, "process_week_transition", week) monkeypatch.setattr(_poke_credit, "process_week_transition", week)
monkeypatch.setattr(_poke_credit, "reset_to_base_if_new_day", reset) 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() _poke_credit.fresh_locker().start_the_day()
assert calls == ["week", "reset", "bonus"] assert calls == ["week", "reset"]
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()
def test_credit_written_sets_the_entry_and_returns_the_result( def test_credit_written_sets_the_entry_and_returns_the_result(
self, log_file: Path, credit: MagicMock self, log_file: Path, credit: MagicMock

View File

@ -86,13 +86,13 @@ class TestCheckNonVerifyExitsExtras:
): ):
locker._check_non_verify_exits() locker._check_non_verify_exits()
def test_applies_weekly_bonus_on_fresh_day_reset( def test_fresh_day_reset_layers_no_weekly_bonus(
self, self,
mock_tk: MagicMock, mock_tk: MagicMock,
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> 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) locker = create_locker(mock_tk, tmp_path)
object.__setattr__( object.__setattr__(
locker, locker,
@ -113,50 +113,6 @@ class TestCheckNonVerifyExitsExtras:
"screen_locker._startup_checks.process_week_transition", "screen_locker._startup_checks.process_week_transition",
return_value=[], 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.is_relaxed_day", return_value=False),
patch( patch(
"screen_locker._startup_checks.has_weekly_minimum", return_value=True "screen_locker._startup_checks.has_weekly_minimum", return_value=True

View File

@ -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 == []

View File

@ -161,15 +161,14 @@ class TestRunStatusNormal:
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Shutdown tonight" not in 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 self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None: ) -> 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" eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=0) locker = _make_locker(tmp_path / "log.json", n_filled=0)
with ( with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file), 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.current_streak", return_value=2),
patch("screen_locker._status.has_extended_early_bird", return_value=True), patch("screen_locker._status.has_extended_early_bird", return_value=True),
patch("screen_locker._status.count_weekly_workouts", return_value=0), patch("screen_locker._status.count_weekly_workouts", return_value=0),
@ -177,7 +176,7 @@ class TestRunStatusNormal:
): ):
run_status(locker) run_status(locker)
out = capsys.readouterr().out 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 "Streak (5+ wks) : 2" in out
assert "Yes — until 09:00" in out assert "Yes — until 09:00" in out

View File

@ -102,10 +102,10 @@ class TestGatherStatus:
assert snap.shutdown.tonight == (1320, 1380, 300) 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 self, tmp_path: Path
) -> None: ) -> None:
"""Bonus streak and extended early bird reflected.""" """Streak + early bird reflected; a legacy banked weekly bonus adds nothing."""
files = _files(tmp_path) files = _files(tmp_path)
files["extra_benefits_file"].write_text( files["extra_benefits_file"].write_text(
json.dumps( json.dumps(
@ -121,10 +121,9 @@ class TestGatherStatus:
): ):
snap = gather_status(**files, now=_FRIDAY_NOON_UTC) snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
assert snap.bonus_hours_this_week == 3
assert snap.streak == 2 assert snap.streak == 2
assert snap.early_bird_extended is True 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( def test_next_week_preview_is_speculative_rest_of_week_is_not(
self, tmp_path: Path self, tmp_path: Path

View File

@ -51,32 +51,29 @@ main() {
validate_requirements validate_requirements
cd "$SCRIPT_DIR" 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 "$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 "$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. # --sync-only never locks, so this is safe to run while disarmed.
./run.sh --sync-only ./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 # 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. # revoke a sick day against, and re-arming here would lock you out again.
"$PY" scripts/verify_workout_credited.py --date "$TARGET_DATE" "$PY" scripts/verify_workout_credited.py --date "$TARGET_DATE"
step "5/7 Restore shutdown hours + grant the bonus" step "5/6 Revoke the sick days that this workout disproves"
"$PY" scripts/restore_and_bonus.py --date "$TARGET_DATE"
step "6/7 Revoke the sick days that this workout disproves"
"$PY" scripts/revoke_sick_day.py --date "$TARGET_DATE" --date 2026-06-12 "$PY" scripts/revoke_sick_day.py --date "$TARGET_DATE" --date 2026-06-12
if ((ARM)); then if ((ARM)); then
step "7/7 Re-arm enforcement" step "6/6 Re-arm enforcement"
./arm.sh --rearm ./arm.sh --rearm
else 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" echo "Re-arm later with: ./arm.sh --rearm"
fi fi

View File

@ -30,10 +30,9 @@ import tempfile
import earned_time import earned_time
from screen_locker import _earned, _grace_floor, _shutdown 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._day import today_str
from screen_locker._earned import hhmm 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._rest_day import declare, is_rest_day
from screen_locker._shutdown import read_shutdown_config from screen_locker._shutdown import read_shutdown_config
from screen_locker._shutdown_target import ( from screen_locker._shutdown_target import (
@ -44,7 +43,6 @@ from screen_locker._shutdown_target import (
gather, gather,
) )
from scripts._preview_sim import ( from scripts._preview_sim import (
HELPER_CEILING,
SimRunnerUp, SimRunnerUp,
SimShutdown, SimShutdown,
sick_history, sick_history,
@ -166,12 +164,7 @@ def main() -> int:
) )
target = gather(today, _LOG) target = gather(today, _LOG)
_report("today, real ledgers", target) _report("today, real ledgers", target)
weekly = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE) * 60 _say(f" expected config after the next reset: {hhmm(target.minutes)}")
_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)))}"
)
return 0 return 0
day = date.fromisoformat(args.day) day = date.fromisoformat(args.day)
_say(f" ladder in force on {day}: {ladder(day) if ladder else False}") _say(f" ladder in force on {day}: {ladder(day) if ladder else False}")

View File

@ -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[<ISO week>]`` -- 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())