mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
The by-minutes bonus path clamped at 24:00 (_MIDNIGHT) and relied on
adjust_shutdown_schedule.sh's RESTORE_CEILING (23:00). It now clamps at
earned_time.shutdown_ceiling_for(day) via the new _ceiling.ceiling, so a
moved WAKE_MINUTES cannot be passed by a bonus. The shell's 1380 stays
the hard upper bound; ceiling() is min()'d with it, and a test pins the
Python constant to the script's value. _preview_sim and the invariant
test reuse that constant instead of a second 23 * 60.
Failure mode: if the earned_time lookup raises (ArithmeticError,
LookupError, TypeError, ValueError), ceiling() falls back to 23:00
loudly (logger.warning), never silently and never above the helper's
bound. That is fail-open relative to a lowered ceiling, bounded by the
shell clamp; an earned_time without the API (0.3.0) uses its constant.
Red first: new tests (21:00 ceiling, failing lookup, caps-at-ceiling)
failed on c3a5d14, pass now. 14-day replay of the real ledgers
(2026-10-18..31) through old and new code, plus an all-done-at-22:30
counterfactual: identical, every day 23:00; live files unchanged.
TODO-cap-bonus-at-day-ceiling.md removed (done).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
105 lines
4.0 KiB
Python
105 lines
4.0 KiB
Python
"""A credit at or after tonight's shutdown counts from its own time.
|
|
|
|
Shutting down must never make a finished task impossible to submit (kuhy,
|
|
2026-10-17). The config is read-add-write, so a credit landing after the
|
|
shutdown used to add its minutes to a time already past: LeetCode at 19:00
|
|
after an 18:00 shutdown made 19:00, and the machine stayed locked. Now such
|
|
a credit moves tonight to ``its own time + its reward``, capped at the
|
|
ceiling -- only the ceiling (23:00) is hard.
|
|
|
|
The anchor is the credit's own time (a run's end, a ledger row's), never the
|
|
moment a sync applied it, so a late upload does not buy extra time. Past the
|
|
shutdown the grace and sick lifts are spent: the caller clears the lift.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime
|
|
import logging
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
from screen_locker._ceiling import ceiling
|
|
from screen_locker._day import today_str
|
|
from screen_locker._earned import hhmm
|
|
from screen_locker._grace_floor import save_lift, tonight_minutes
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _now() -> float:
|
|
"""The clock a credit without its own time is stamped with."""
|
|
return time.time()
|
|
|
|
|
|
def late_tonight(
|
|
config: tuple[int, int, int], day: date, at: float, minutes: int
|
|
) -> int | None:
|
|
"""Tonight's new shutdown for a late credit, or ``None`` if it is not late.
|
|
|
|
Late means timed on ``day`` at or after tonight's shutdown: the check
|
|
script locks at ``now >= shutdown``, so a credit exactly on it is late.
|
|
"""
|
|
moment = datetime.fromtimestamp(at).astimezone()
|
|
if moment.date() != day:
|
|
return None
|
|
done = moment.hour * 60 + moment.minute
|
|
if done < tonight_minutes(config, day):
|
|
return None
|
|
return min(ceiling(day), done + minutes)
|
|
|
|
|
|
class LateCreditMixin:
|
|
"""Late credits and sick-day credits; composed into ShutdownMixin."""
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
def _read_shutdown_config(self) -> tuple[int, int, int] | None: ...
|
|
|
|
def _write_shutdown_config(
|
|
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ...
|
|
) -> bool: ...
|
|
|
|
def _apply_sick_floor(self) -> bool: ...
|
|
|
|
def _raise_on_sick_day(self, minutes: int, at: float | None) -> bool:
|
|
"""A sick day's credit: raise to the derived target, never add.
|
|
|
|
The credit's row is already in the target (``_sick_floor``), so adding
|
|
its minutes on top would pay it twice; a late one still counts from
|
|
its own time. True even when nothing moved (capped, or already there):
|
|
the credit is accounted for, and a failed write is retried by the next
|
|
tick's sick pass.
|
|
"""
|
|
self._apply_sick_floor()
|
|
late = self._late_write(minutes, at)
|
|
return True if late is None else late
|
|
|
|
def _late_write(self, minutes: int, at: float | None) -> bool | None:
|
|
"""Write a late credit's shutdown; ``None`` when it is not late."""
|
|
day = date.fromisoformat(today_str())
|
|
try:
|
|
config = self._read_shutdown_config()
|
|
except (OSError, ValueError) as exc:
|
|
# The add path reads it again inside its own guard and fails there.
|
|
_logger.warning("Late credit: cannot read shutdown config (%s)", exc)
|
|
return None
|
|
if config is None:
|
|
return None # the add path reads it again and reports it
|
|
target = late_tonight(config, day, _now() if at is None else at, minutes)
|
|
if target is None:
|
|
return None
|
|
mon_wed, thu_sun, morning = config
|
|
if not self._write_shutdown_config(
|
|
max(mon_wed, target), max(thu_sun, target), morning, restore=True
|
|
):
|
|
_logger.warning("Late credit: failed to write shutdown config")
|
|
return False
|
|
save_lift(0) # past the shutdown, the floors' lift is spent
|
|
_logger.info(
|
|
"Late credit: shutdown %s -> %s (earned at or after tonight's).",
|
|
hhmm(tonight_minutes(config, day)),
|
|
hhmm(target),
|
|
)
|
|
return True
|