screen-locker/screen_locker/_shutdown_base.py
Krzysztof kuhy Rudnicki 6a07dc56aa
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 (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Reading hour from book-guard: +1h shutdown; base 20->19 from 2026-10-01
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 19:02:24 +02:00

244 lines
9.1 KiB
Python

"""Daily shutdown-time base reset for the screen locker.
On each new calendar day the shutdown config is reset to the base hour
(:data:`BASE_HOUR`, 19:00) so that the day's bonuses always layer on top of a
known floor rather than accumulating indefinitely across days.
The base is a constant, not state. It used to be persisted in the state file
and read back on every reset, which made the code's default dead: the file
kept re-writing whatever it was installed with, so changing the constant
changed nothing on the machine. The state file now carries only date stamps.
The reset re-derives what today has ALREADY earned and writes base plus that
in one go: the workout hours from today's log, the LeetCode hour from
leetcode-guard's ledger (:mod:`screen_locker._leetcode_bonus`) and the reading
hour from book-guard's (:mod:`screen_locker._reading_bonus`). 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. Any bonus source that is not a
term of this derivation has that bug.
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.
"""
from __future__ import annotations
from datetime import date, datetime
import json
import logging
from typing import TYPE_CHECKING, Any
from screen_locker._day import today_str
from screen_locker._leetcode_bonus import LEETCODE_BONUS_HOURS, leetcode_bonus_hours
from screen_locker._log_io import load_workout_log
from screen_locker._reading_bonus import READING_BONUS_HOURS, reading_bonus_hours
from screen_locker._weekly_check import count_day_credits
from screen_locker._workout_credit import earned_shutdown_bonus_hours
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
_logger = logging.getLogger(__name__)
# The hour every day starts from before any bonus. Moved 21 -> 20 on
# 2026-09-15, and 20 -> 19 together with book-guard's reading hour: the
# ceiling stays 23:00, so every day's best case (workouts + LeetCode + reading)
# is unchanged and a day without reading ends an hour earlier. The cut waits
# for book-guard's gate (2026-10-01) -- taking an hour the reader cannot yet
# earn back was a same-day loss when it first shipped on 2026-09-26.
BASE_HOUR = 19
READING_BASE_FROM = date(2026, 10, 1)
def base_hour(day: date | None = None) -> int:
"""The base for ``day`` (default today): 20 before the reading cut, then 19."""
target = day or datetime.now().astimezone().date()
return BASE_HOUR if target >= READING_BASE_FROM else BASE_HOUR + 1
# 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
_LEETCODE_STAMP = "leetcode_bonus_date"
_READING_STAMP = "reading_bonus_date"
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 _load_state(state_file: Path) -> dict[str, Any]:
"""Return the reset state, or an empty dict when it is missing or corrupt.
A corrupt file is logged and treated as "nothing stamped": the reset then
runs again, which is idempotent, and the LeetCode hour is re-applied at
worst once, capped by the ceiling.
"""
if not state_file.exists():
return {}
try:
with state_file.open() as f:
state: dict[str, Any] = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
_logger.warning(
"Could not read shutdown reset state from %s: %s -- treating today "
"as not yet reset",
state_file,
exc,
)
return {}
return state
def _save_state(state_file: Path, state: dict[str, Any]) -> None:
"""Write the reset state, logging rather than raising on failure."""
try:
with state_file.open("w") as f:
json.dump(state, f, indent=2)
except OSError as exc:
_logger.warning("Failed to write shutdown reset state: %s", exc)
def _clear_sick_day_state(sick_day_state_file: Path | None) -> None:
"""Remove stale sick-day state so it does not override the base reset."""
if sick_day_state_file is None or not sick_day_state_file.exists():
return
try:
sick_day_state_file.unlink()
_logger.info("Daily base reset: cleared stale sick-day state.")
except OSError as exc:
_logger.warning("Daily base reset: could not remove sick-day state: %s", exc)
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 the base hour if a new calendar day began.
Writes :data:`BASE_HOUR` plus whatever today has already earned -- the
workout hours in *log_file* (see :func:`today_earned_bonus_hours`) and the
LeetCode hour -- via *mixin*._write_shutdown_config (with restore=True so
the script allows moving the time earlier), stamps ``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.
"""
today = today_str()
if _load_state(state_file).get("last_reset_date") == today:
return False
earned = today_earned_bonus_hours(log_file, today) if log_file is not None else 0
leetcode = leetcode_bonus_hours()
reading = reading_bonus_hours()
base = base_hour()
target = min(_RESTORE_CEILING_HOUR, base + earned + leetcode + reading)
# 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(target, target, morning_end, restore=True)
if not ok:
_logger.warning("Daily base reset: failed to write shutdown config.")
return False
_clear_sick_day_state(sick_day_state_file)
# The flat hours are stamped here too: the reset already included them,
# so the live pass below must not add them a second time today.
new_state: dict[str, Any] = {"last_reset_date": today}
if leetcode:
new_state[_LEETCODE_STAMP] = today
if reading:
new_state[_READING_STAMP] = today
_save_state(state_file, new_state)
_logger.info(
"Daily base reset: %02d:00 (base %d + %dh workout + %dh LeetCode + "
"%dh reading already earned today).",
target,
base,
earned,
leetcode,
reading,
)
return True
def _apply_flat_bonus(
state_file: Path,
mixin: object,
*,
stamp: str,
hours: Callable[[], int],
label: str,
) -> bool:
"""Push shutdown later by a once-per-day flat hour, if it is earned.
The daily reset only sees a bonus earned before it ran; this is the live
counterpart for the usual case, where the solve or the reading lands
hours later and is picked up by the next timer tick. Idempotent through
the ``stamp`` key in *state_file*, which the reset sets as well when it
already included the hour.
Returns True if the hour was applied on this call.
"""
today = today_str()
state = _load_state(state_file)
if state.get(stamp) == today:
return False
earned = hours()
if earned == 0:
return False
if not mixin._adjust_shutdown_time_by(earned):
_logger.warning("%s bonus: failed to write shutdown config.", label)
return False
state[stamp] = today
_save_state(state_file, state)
_logger.info("%s bonus: +%dh shutdown time today.", label, earned)
return True
def apply_leetcode_bonus_if_new(state_file: Path, mixin: object) -> bool:
"""The LeetCode hour, live (see :func:`_apply_flat_bonus`)."""
return _apply_flat_bonus(
state_file,
mixin,
stamp=_LEETCODE_STAMP,
hours=lambda: LEETCODE_BONUS_HOURS if leetcode_bonus_hours() else 0,
label="LeetCode",
)
def apply_reading_bonus_if_new(state_file: Path, mixin: object) -> bool:
"""The reading hour, live (see :func:`_apply_flat_bonus`)."""
return _apply_flat_bonus(
state_file,
mixin,
stamp=_READING_STAMP,
hours=lambda: READING_BONUS_HOURS if reading_bonus_hours() else 0,
label="Reading",
)
def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None:
"""Every once-per-day flat hour: LeetCode, then reading."""
apply_leetcode_bonus_if_new(state_file, mixin)
apply_reading_bonus_if_new(state_file, mixin)