Replay today's earned shutdown credit in the daily base reset

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
This commit is contained in:
Krzysztof Rudnicki 2026-09-13 20:00:05 +02:00
parent f79b3c69cf
commit 82f3462896
7 changed files with 313 additions and 15 deletions

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

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

View File

@ -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()

View File

@ -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))