mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 18:53:55 +01:00
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
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:
parent
87c50825f9
commit
fc5453495c
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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]]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
|
||||||
|
|||||||
@ -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``.
|
||||||
|
|||||||
@ -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",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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)."""
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
206
screen_locker/tests/test_shutdown_ceiling_invariant.py
Normal file
206
screen_locker/tests/test_shutdown_ceiling_invariant.py
Normal 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 == []
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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())
|
|
||||||
Loading…
Reference in New Issue
Block a user