From 82f3462896aa8a6ef1e972d52d0b741d599ea679 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 13 Sep 2026 20:00:05 +0200 Subject: [PATCH] Replay today's earned shutdown credit in the daily base reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The i3bar showed 21:00 on 2026-09-13 with a counted workout on disk. The football was logged at 00:02 and credited against the previous day's config (already at the 23 ceiling, so it bought nothing); the 09:15 base reset then wrote 21/21 and only re-layered the weekly bank, never asking what today's log had already earned. Live credits are read-add-write, so every workout credited between local midnight and the first locker tick of the day lost its hours to the reset that followed. - _shutdown_base.reset_to_base_if_new_day takes the log and writes min(23, base + hours today's entries earned) in one write, using the same credit rule as the weekly count. Idempotent: still once per day. - _workout_credit names FIRST_WORKOUT_BONUS_HOURS=2 / EXTRA_WORKOUT_BONUS_HOURS=1 and derives earned_shutdown_bonus_hours() from them; _shutdown and the +1h path use the constants instead of literals so the replay and the live paths cannot drift. - _weekly_check.credit_key: a synced copy whose sync_record_id names an entry already filed on the same day shares that entry's slot. This morning's rekey (967e3cd) let the sync pull the football back in as manual:2026-09-13T14:30, which counted as a second credit (weekly 4/5 instead of 3/5) and would have made the replay ask for 24. - annotate_duplicate_credits.py passes the day's id index so the ledger records the manual duplicate the same way it records phone/PC pairs. Verified through the real path: cleared last_reset_date, started workout-locker.service -> "--restore 23 23 5", "Daily base reset: Mon-Wed=23, Thu-Sun=23 (base 21/21 + 2h already earned today)", shutdown_countdown.sh prints "23:00"; a second start changed nothing. leetcode-guard grants no shutdown time anywhere (grepped screen-locker, digital-wellbeing, i3 config) — the 23:00 comes from the workout alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP --- screen_locker/_shutdown.py | 5 +- screen_locker/_shutdown_base.py | 52 ++++- screen_locker/_startup_checks.py | 5 +- screen_locker/_weekly_check.py | 38 +++- screen_locker/_workout_credit.py | 18 +- .../tests/test_shutdown_base_replay.py | 206 ++++++++++++++++++ scripts/annotate_duplicate_credits.py | 4 +- 7 files changed, 313 insertions(+), 15 deletions(-) create mode 100644 screen_locker/tests/test_shutdown_base_replay.py diff --git a/screen_locker/_shutdown.py b/screen_locker/_shutdown.py index 786083c..5eccc59 100644 --- a/screen_locker/_shutdown.py +++ b/screen_locker/_shutdown.py @@ -13,6 +13,7 @@ from screen_locker._constants import ( from screen_locker._day import today_str from screen_locker._shutdown_sick_state import SickDayStateMixin from screen_locker._wake_alarm import WakeAlarmMixin +from screen_locker._workout_credit import FIRST_WORKOUT_BONUS_HOURS if TYPE_CHECKING: from pathlib import Path @@ -97,8 +98,8 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin): if config_values is None: return False mon_wed_hour, thu_sun_hour, morning_end_hour = config_values - new_mon_wed = min(23, mon_wed_hour + 2) - new_thu_sun = min(23, thu_sun_hour + 2) + new_mon_wed = min(23, mon_wed_hour + FIRST_WORKOUT_BONUS_HOURS) + new_thu_sun = min(23, thu_sun_hour + FIRST_WORKOUT_BONUS_HOURS) return self._write_shutdown_config( new_mon_wed, new_thu_sun, diff --git a/screen_locker/_shutdown_base.py b/screen_locker/_shutdown_base.py index 5d066e7..5956aa0 100644 --- a/screen_locker/_shutdown_base.py +++ b/screen_locker/_shutdown_base.py @@ -4,6 +4,12 @@ On each new calendar day the shutdown config is reset to base hours (21:00 by default) so that the day's workout bonuses always layer on top of a known floor rather than accumulating indefinitely across days. +The reset re-derives what today's log has ALREADY earned and writes base plus +that in one go. Live credits are read-add-write against the config, so a +workout credited between local midnight and the first locker tick of the day +(a manual log at 00:02, say) had its hours wiped by the reset that followed — +on 2026-09-13 that left the bar at 21:00 with a counted workout on disk. + The sick-day state file is cleared on reset so the sick-restore path cannot overwrite the fresh base when it runs later in the same startup. """ @@ -15,6 +21,9 @@ import logging from typing import TYPE_CHECKING, Any from screen_locker._day import today_str +from screen_locker._log_io import load_workout_log +from screen_locker._weekly_check import count_day_credits +from screen_locker._workout_credit import earned_shutdown_bonus_hours if TYPE_CHECKING: from pathlib import Path @@ -22,6 +31,21 @@ if TYPE_CHECKING: _logger = logging.getLogger(__name__) _DEFAULT_BASE_HOUR = 21 +# Mirrors RESTORE_CEILING in adjust_shutdown_schedule.sh: the script clamps any +# restore above it, so the target is computed against the same ceiling here +# rather than asking for hours the write will silently cut. +_RESTORE_CEILING_HOUR = 23 + + +def today_earned_bonus_hours(log_file: Path, today: str) -> int: + """Return the shutdown hours today's logged workouts have already earned. + + Counted with the same :func:`~screen_locker._weekly_check.count_day_credits` + rule as the weekly total, so a workout recorded twice earns once. + """ + entries = load_workout_log(log_file).get(today, []) + credit_count = count_day_credits(today, [e for e in entries if isinstance(e, dict)]) + return earned_shutdown_bonus_hours(credit_count) def get_base_hours(state_file: Path) -> tuple[int, int]: @@ -53,13 +77,16 @@ def reset_to_base_if_new_day( state_file: Path, mixin: object, sick_day_state_file: Path | None = None, + log_file: Path | None = None, ) -> bool: """Reset the shutdown config to base hours if a new calendar day has begun. - Writes base hours via *mixin*._write_shutdown_config (with restore=True so - the script allows moving the time earlier), updates ``last_reset_date`` in - *state_file*, and removes *sick_day_state_file* if it exists so the - sick-restore path does not fight with the fresh base on the same startup. + Writes base hours plus whatever *log_file* shows today has already earned + (see :func:`today_earned_bonus_hours`) via *mixin*._write_shutdown_config + (with restore=True so the script allows moving the time earlier), updates + ``last_reset_date`` in *state_file*, and removes *sick_day_state_file* if + it exists so the sick-restore path does not fight with the fresh base on + the same startup. Returns True if a reset was performed, False if today was already reset. """ @@ -80,12 +107,17 @@ def reset_to_base_if_new_day( ) base_mw, base_ts = get_base_hours(state_file) + earned = today_earned_bonus_hours(log_file, today) if log_file is not None else 0 + target_mw = min(_RESTORE_CEILING_HOUR, base_mw + earned) + target_ts = min(_RESTORE_CEILING_HOUR, base_ts + earned) # Preserve the morning-end hour from the live config. config = mixin._read_shutdown_config() morning_end = config[2] if config else 5 - ok: bool = mixin._write_shutdown_config(base_mw, base_ts, morning_end, restore=True) + ok: bool = mixin._write_shutdown_config( + target_mw, target_ts, morning_end, restore=True + ) if not ok: _logger.warning("Daily base reset: failed to write shutdown config.") return False @@ -111,5 +143,13 @@ def reset_to_base_if_new_day( except OSError as exc: _logger.warning("Daily base reset: failed to write state file: %s", exc) - _logger.info("Daily base reset: Mon-Wed=%d, Thu-Sun=%d.", base_mw, base_ts) + _logger.info( + "Daily base reset: Mon-Wed=%d, Thu-Sun=%d (base %d/%d + %dh already " + "earned today).", + target_mw, + target_ts, + base_mw, + base_ts, + earned, + ) return True diff --git a/screen_locker/_startup_checks.py b/screen_locker/_startup_checks.py index e6cee96..8e6e386 100644 --- a/screen_locker/_startup_checks.py +++ b/screen_locker/_startup_checks.py @@ -108,7 +108,10 @@ class StartupChecksMixin(SyncMixin): # Reset shutdown config to base (21:00) at the start of each new day, # then layer this week's earned bonus back on top of the fresh base. if reset_to_base_if_new_day( - SHUTDOWN_BASE_FILE, self, sick_day_state_file=SICK_DAY_STATE_FILE + SHUTDOWN_BASE_FILE, + self, + sick_day_state_file=SICK_DAY_STATE_FILE, + log_file=self.log_file, ): self._apply_weekly_shutdown_bonus() # Ingest any manual workouts synced from the phone (or another device) diff --git a/screen_locker/_weekly_check.py b/screen_locker/_weekly_check.py index af44d7a..252c7a4 100644 --- a/screen_locker/_weekly_check.py +++ b/screen_locker/_weekly_check.py @@ -91,6 +91,7 @@ def credit_key( date_str: str, entry_index: int, entry: Mapping[str, Any], + sibling_index: Mapping[str, int] | None = None, ) -> tuple[str, str] | None: """Return the credit slot a log entry occupies, or None if it earns none. @@ -104,6 +105,10 @@ def credit_key( entry_index: Position of the entry within that date's list, used to keep independently-earned credits distinct. entry: The log entry. + sibling_index: ``workout_id`` → position for every entry filed under + the same day (see :func:`day_workout_index`). A synced copy names + the entry it was pulled from in ``sync_record_id``; when that entry + is already here, the copy is the same workout and shares its slot. Returns: A hashable key shared by entries that represent the same workout, or @@ -120,9 +125,33 @@ def credit_key( # Verified runs and manual workouts are genuinely separate sessions, so # several on one day all count -- the manual-workout rate budget (see # screen_locker._manual_workout) is the anti-gaming limiter, not a collapse. + # The one exception is a sync re-ingesting an entry that is already on + # this day under a different workout_id (2026-09-13: a manual workout + # rekeyed to its local day, then pulled back in stamped with that day). + sync_id = ( + workout_data.get("sync_record_id") + if isinstance(workout_data, Mapping) + else None + ) + if sibling_index and isinstance(sync_id, str) and sync_id in sibling_index: + entry_index = sibling_index[sync_id] return (wtype, f"{date_str}#{entry_index}") +def day_workout_index(entries: Iterable[Mapping[str, Any]]) -> dict[str, int]: + """Map each ``workout_id`` on one day to the position of its first entry. + + Built once per day and handed to :func:`credit_key`, so a synced copy can + resolve its ``sync_record_id`` to the slot of the entry it duplicates. + """ + index: dict[str, int] = {} + for position, entry in enumerate(entries): + wid = entry.get("workout_id") + if wid is not None: + index.setdefault(str(wid), position) + return index + + def count_day_credits(date_str: str, entries: Iterable[Mapping[str, Any]]) -> int: """Count the distinct workout credits earned on one day. @@ -132,12 +161,15 @@ def count_day_credits(date_str: str, entries: Iterable[Mapping[str, Any]]) -> in Returns: The number of distinct credits, after collapsing the two StrongLifts - ingestion paths onto one slot. + ingestion paths (and any synced copy of an entry already present) + onto one slot. """ + day_entries = list(entries) + sibling_index = day_workout_index(day_entries) keys = { key - for index, entry in enumerate(entries) - if (key := credit_key(date_str, index, entry)) is not None + for index, entry in enumerate(day_entries) + if (key := credit_key(date_str, index, entry, sibling_index)) is not None } return len(keys) diff --git a/screen_locker/_workout_credit.py b/screen_locker/_workout_credit.py index 95bb11b..9277839 100644 --- a/screen_locker/_workout_credit.py +++ b/screen_locker/_workout_credit.py @@ -25,6 +25,22 @@ if TYPE_CHECKING: _logger = logging.getLogger(__name__) +# The shutdown reward for a day's workouts: the first counted workout pushes +# shutdown later by FIRST_WORKOUT_BONUS_HOURS, every further one by +# EXTRA_WORKOUT_BONUS_HOURS. Both the live credit paths below and the daily +# base reset (``_shutdown_base``) derive from these two numbers, so a day's +# earned hours can be recomputed from the log instead of being lost when the +# credit landed before the reset ran. +FIRST_WORKOUT_BONUS_HOURS = 2 +EXTRA_WORKOUT_BONUS_HOURS = 1 + + +def earned_shutdown_bonus_hours(credit_count: int) -> int: + """Return the shutdown hours a day with *credit_count* distinct workouts earned.""" + if credit_count <= 0: + return 0 + return FIRST_WORKOUT_BONUS_HOURS + (credit_count - 1) * EXTRA_WORKOUT_BONUS_HOURS + @dataclass(frozen=True) class WorkoutCreditResult: @@ -112,7 +128,7 @@ class WorkoutCreditMixin: shutdown_adjusted = self._try_adjust_shutdown_for_workout() elif self.workout_data.get("type") in COUNTED_WORKOUT_TYPES: old_cfg = self._read_shutdown_config() - if old_cfg and self._adjust_shutdown_time_by(1): + if old_cfg and self._adjust_shutdown_time_by(EXTRA_WORKOUT_BONUS_HOURS): new_cfg = self._read_shutdown_config() if new_cfg: extra_bonus_delta = new_cfg[1] - old_cfg[1] diff --git a/screen_locker/tests/test_shutdown_base_replay.py b/screen_locker/tests/test_shutdown_base_replay.py new file mode 100644 index 0000000..a0b7580 --- /dev/null +++ b/screen_locker/tests/test_shutdown_base_replay.py @@ -0,0 +1,206 @@ +"""Tests for the daily reset replaying today's already-earned shutdown credit. + +Guards the 2026-09-13 failure: a manual workout logged at 00:02 earned its +hours against the previous day's config, the 09:15 base reset wiped them, and +the bar sat at 21:00 with a counted workout on disk. The reset must now write +``base + what today's log has earned``, counted by the same credit rule as the +weekly total so a synced duplicate of one workout earns once. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock + +from screen_locker._day import today_str +from screen_locker._shutdown_base import ( + reset_to_base_if_new_day, + today_earned_bonus_hours, +) +from screen_locker._weekly_check import ( + count_day_credits, + credit_key, + day_workout_index, +) +from screen_locker._workout_credit import earned_shutdown_bonus_hours + +if TYPE_CHECKING: + from pathlib import Path + +_DAY = "2026-09-13" + + +def _manual(workout_id: str, *, sync_record_id: str | None = None) -> dict[str, Any]: + """Build a manual_workout entry, optionally stamped as a synced copy.""" + data: dict[str, Any] = {"type": "manual_workout", "source": "football 11v11"} + if sync_record_id is not None: + data["sync_record_id"] = sync_record_id + return { + "timestamp": f"{_DAY}T07:40:06+00:00", + "workout_data": data, + "workout_id": workout_id, + } + + +def _real_2026_09_13() -> list[dict[str, Any]]: + """The two entries log.json held for 2026-09-13: one football, twice.""" + return [ + _manual("manual:2026-09-12T14:30"), + _manual("manual:2026-09-13T14:30", sync_record_id="manual:2026-09-12T14:30"), + ] + + +def _write_log(log_file: Path, day: str, entries: list[dict[str, Any]]) -> Path: + log_file.write_text(json.dumps({day: entries})) + return log_file + + +class TestEarnedShutdownBonusHours: + """First credit is worth 2h, every further one 1h.""" + + def test_no_credits_earn_nothing(self) -> None: + assert earned_shutdown_bonus_hours(0) == 0 + + def test_negative_is_treated_as_nothing(self) -> None: + assert earned_shutdown_bonus_hours(-1) == 0 + + def test_first_credit_is_two_hours(self) -> None: + assert earned_shutdown_bonus_hours(1) == 2 + + def test_further_credits_add_one_hour_each(self) -> None: + assert earned_shutdown_bonus_hours(3) == 4 + + +class TestSyncedCopySharesCredit: + """A sync re-ingesting an entry already on the day is the same workout.""" + + def test_real_day_counts_one_credit(self) -> None: + assert count_day_credits(_DAY, _real_2026_09_13()) == 1 + + def test_copy_and_original_share_a_key(self) -> None: + entries = _real_2026_09_13() + index = day_workout_index(entries) + assert credit_key(_DAY, 0, entries[0], index) == credit_key( + _DAY, 1, entries[1], index + ) + + def test_sync_id_naming_an_absent_entry_stays_separate(self) -> None: + """A synced manual whose source is NOT on this day is its own workout.""" + entries = [ + _manual("manual:2026-09-13T08:00"), + _manual( + "manual:2026-09-13T18:00", sync_record_id="manual:2026-09-13T18:00" + ), + ] + assert count_day_credits(_DAY, entries) == 2 + + def test_two_genuine_manual_workouts_still_both_count(self) -> None: + entries = [ + _manual("manual:2026-09-13T08:00"), + _manual("manual:2026-09-13T18:00"), + ] + assert count_day_credits(_DAY, entries) == 2 + + def test_without_sibling_index_keys_by_position(self) -> None: + copy = _real_2026_09_13()[1] + assert credit_key(_DAY, 3, copy) == ("manual_workout", f"{_DAY}#3") + + def test_day_workout_index_skips_entries_without_an_id(self) -> None: + entries = [{"workout_data": {"type": "x"}}, _manual("manual:2026-09-13T08:00")] + assert day_workout_index(entries) == {"manual:2026-09-13T08:00": 1} + + def test_day_workout_index_keeps_the_first_position_of_a_repeated_id(self) -> None: + entries = [ + _manual("manual:2026-09-13T08:00"), + _manual("manual:2026-09-13T08:00"), + ] + assert day_workout_index(entries) == {"manual:2026-09-13T08:00": 0} + + def test_non_string_sync_id_is_ignored(self) -> None: + entry = _manual("manual:2026-09-13T08:00") + entry["workout_data"]["sync_record_id"] = 42 + assert credit_key(_DAY, 0, entry, {"42": 5}) == ("manual_workout", f"{_DAY}#0") + + +class TestTodayEarnedBonusHours: + """The log → hours derivation the reset relies on.""" + + def test_real_day_earns_two_hours(self, tmp_path: Path) -> None: + log = _write_log(tmp_path / "log.json", _DAY, _real_2026_09_13()) + assert today_earned_bonus_hours(log, _DAY) == 2 + + def test_day_absent_from_log_earns_nothing(self, tmp_path: Path) -> None: + log = _write_log(tmp_path / "log.json", "2026-09-12", _real_2026_09_13()) + assert today_earned_bonus_hours(log, _DAY) == 0 + + def test_non_dict_entries_are_ignored(self, tmp_path: Path) -> None: + log = tmp_path / "log.json" + log.write_text( + json.dumps({_DAY: ["garbage", _manual("manual:2026-09-13T08:00")]}) + ) + assert today_earned_bonus_hours(log, _DAY) == 2 + + +class TestResetReplaysTodaysCredit: + """reset_to_base_if_new_day writes base + earned, capped at the ceiling.""" + + def _mixin(self) -> MagicMock: + mixin = MagicMock() + mixin._read_shutdown_config.return_value = (21, 21, 5) + mixin._write_shutdown_config.return_value = True + return mixin + + def _state(self, tmp_path: Path, base: int = 21) -> Path: + state = tmp_path / "state.json" + state.write_text( + json.dumps( + { + "last_reset_date": "2000-01-01", + "base_mon_wed_hour": base, + "base_thu_sun_hour": base, + } + ) + ) + return state + + def test_writes_base_plus_todays_earned_hours(self, tmp_path: Path) -> None: + """The 2026-09-13 case: base 21 + one football → 23, not 21.""" + log = _write_log(tmp_path / "log.json", today_str(), _real_2026_09_13()) + mixin = self._mixin() + assert ( + reset_to_base_if_new_day(self._state(tmp_path), mixin, log_file=log) is True + ) + mixin._write_shutdown_config.assert_called_once_with(23, 23, 5, restore=True) + + def test_without_log_file_writes_plain_base(self, tmp_path: Path) -> None: + mixin = self._mixin() + assert reset_to_base_if_new_day(self._state(tmp_path), mixin) is True + mixin._write_shutdown_config.assert_called_once_with(21, 21, 5, restore=True) + + def test_empty_day_writes_plain_base(self, tmp_path: Path) -> None: + log = _write_log(tmp_path / "log.json", "2000-01-01", _real_2026_09_13()) + mixin = self._mixin() + assert ( + reset_to_base_if_new_day(self._state(tmp_path), mixin, log_file=log) is True + ) + mixin._write_shutdown_config.assert_called_once_with(21, 21, 5, restore=True) + + def test_earned_hours_are_capped_at_the_restore_ceiling( + self, tmp_path: Path + ) -> None: + """base 22 + 2h earned would be 24; the script clamps at 23, so ask for 23.""" + log = _write_log(tmp_path / "log.json", today_str(), _real_2026_09_13()) + mixin = self._mixin() + state = self._state(tmp_path, base=22) + assert reset_to_base_if_new_day(state, mixin, log_file=log) is True + mixin._write_shutdown_config.assert_called_once_with(23, 23, 5, restore=True) + + def test_state_file_still_records_base_not_target(self, tmp_path: Path) -> None: + """Tomorrow's reset must start from 21 again, not from today's 23.""" + log = _write_log(tmp_path / "log.json", today_str(), _real_2026_09_13()) + state = self._state(tmp_path) + reset_to_base_if_new_day(state, self._mixin(), log_file=log) + saved = json.loads(state.read_text()) + assert (saved["base_mon_wed_hour"], saved["base_thu_sun_hour"]) == (21, 21) + assert saved["last_reset_date"] == today_str() diff --git a/scripts/annotate_duplicate_credits.py b/scripts/annotate_duplicate_credits.py index 0d0c5b6..7183969 100755 --- a/scripts/annotate_duplicate_credits.py +++ b/scripts/annotate_duplicate_credits.py @@ -38,7 +38,7 @@ import sys from typing import TypedDict from screen_locker._log_io import load_workout_log -from screen_locker._weekly_check import count_day_credits, credit_key +from screen_locker._weekly_check import count_day_credits, credit_key, day_workout_index _logger = logging.getLogger(__name__) @@ -98,7 +98,7 @@ def _shared_credits(date_str: str, entries: list[dict]) -> list[CreditRecord]: """ groups: dict[tuple[str, str], list[tuple[int, dict]]] = defaultdict(list) for index, entry in enumerate(entries): - key = credit_key(date_str, index, entry) + key = credit_key(date_str, index, entry, day_workout_index(entries)) if key is not None: groups[key].append((index, entry))