screen-locker/screen_locker/_grace_floor.py

206 lines
8.0 KiB
Python

"""Grace floor: shutdown is never earlier than the day's first task + 60 min.
Someone who gets home at 18:10 and finishes their first task at 18:55 is not
cut off at 19:00. The floor is ``first done + GRACE_MINUTES``, capped at the
day's ceiling. "First done" is the earliest of every ledger earner's first
verified credit (``earned_time.first_credit_at``) and the workout's own
completion time -- the end of the run or walk, never the moment a sync
happened to log it, so a late upload does not move the floor later.
**A floor, not a bonus.** The config is otherwise read-add-write: every
earner adds its minutes to whatever is there. Lifting it to the floor and
then letting the next earner add on top would pay the lift twice (anki at
21:00 lifts to 22:00; reading then makes 22:30 where max(earned, floor) is
22:00). So the lift is remembered for the day in ``grace_floor.json`` and
later additions are paid out of it first: with ``E`` earned, floor ``G`` and
lift ``L = max(0, G - E)``, adding ``m`` moves the config by
``max(0, m - L)`` and leaves ``L' = max(0, L - m)`` -- which keeps the config
at ``max(E + m, G)`` without ever needing to know ``E``.
"""
from __future__ import annotations
from datetime import date, datetime
import json
import logging
from typing import TYPE_CHECKING
import earned_time
from screen_locker._bonus_lock import bonus_lock
from screen_locker._constants import GRACE_MINUTES, GRACE_STATE_FILE
from screen_locker._day import today_str
from screen_locker._earned import ceiling, first_credit_time, flat_earners, hhmm
from screen_locker._log_io import load_workout_log
from screen_locker._rest_day import is_rest_day
from screen_locker._sick_tracker import is_sick_day, load_history
from screen_locker._weekly_check import credit_key, day_workout_index
from screen_locker._workout_ledger import entry_done_at
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
_MON_WED = frozenset({0, 1, 2})
def workout_done_at(log_file: Path, day: date) -> float | None:
"""The completion time of ``day``'s first counted workout, if any."""
iso = day.isoformat()
entries = [
e for e in load_workout_log(log_file).get(iso, []) if isinstance(e, dict)
]
siblings = day_workout_index(entries)
times = [
stamp
for index, entry in enumerate(entries)
if credit_key(iso, index, entry, siblings) is not None
and (stamp := entry_done_at(entry, day)) is not None
]
return min(times, default=None)
def _workout_time(day: date, log_file: Path | None, *, rest_day: bool) -> float | None:
"""When ``day``'s first workout was done: the ledger's and the log's view.
The workout ledger (``earned_time`` >= 0.4) holds only RunnerUp and rest
days, so the log still answers for manual and StrongLifts workouts. On a
rest day the ledger is skipped: its rest row is stamped at the day's
midnight, which is not a completion and would otherwise erase the floor.
"""
times: list[float | None] = []
if log_file is not None:
times.append(workout_done_at(log_file, day))
if not rest_day and earned_time.WORKOUT.ledger is not None:
times.append(first_credit_time(earned_time.WORKOUT, day))
return min((t for t in times if t is not None), default=None)
def first_done_at(
day: date, log_file: Path | None, *, rest_day: bool = False
) -> float | None:
"""The earliest moment any earner was done on ``day``; ``None`` if none."""
times = [first_credit_time(e, day) for e in flat_earners()]
times.append(_workout_time(day, log_file, rest_day=rest_day))
on_day = [
t
for t in times
if t is not None and datetime.fromtimestamp(t).astimezone().date() == day
]
return min(on_day, default=None)
def sick_on(day: date) -> bool:
"""Whether ``day`` is a sick day, from the sick-day history itself.
On a sick day the sick-day shutdown wins and there is no grace floor (the
user's call, 2026-10-09): the floor would otherwise lift the sick-day
reduction straight back up.
"""
return is_sick_day(load_history(), today=day.isoformat())
def grace_for(first_done: float | None, day: date) -> int | None:
"""The floor for ``day`` in minutes after midnight, or ``None`` if none."""
if first_done is None:
return None
moment = datetime.fromtimestamp(first_done).astimezone()
if moment.date() != day:
return None
done = moment.hour * 60 + moment.minute
return min(ceiling(day), done + GRACE_MINUTES)
def _load_lift(state_file: Path, today: str) -> int:
if not state_file.exists():
return 0 # no lift recorded yet: nothing to absorb
try:
raw = json.loads(state_file.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
_logger.warning(
"Grace state %s unreadable (%s); lift taken as 0", state_file, exc
)
return 0
if not isinstance(raw, dict) or raw.get("date") != today:
return 0
return max(0, int(raw.get("lift", 0)))
def save_lift(lift: int, state_file: Path | None = None) -> None:
"""Record how far the floor lifts today's shutdown above the earned time."""
state_file = state_file or GRACE_STATE_FILE
payload = {"date": today_str(), "lift": max(0, lift)}
try:
state_file.write_text(json.dumps(payload), encoding="utf-8")
except OSError as exc:
_logger.warning("Could not record the grace lift in %s (%s)", state_file, exc)
def plan_absorb(minutes: int, state_file: Path | None = None) -> tuple[int, int]:
"""How much of an ``minutes`` addition moves the config, and the lift left.
Commit the lift with :func:`save_lift` only once the write succeeded.
"""
lift = _load_lift(state_file or GRACE_STATE_FILE, today_str())
return max(0, minutes - lift), max(0, lift - minutes)
def tonight_minutes(config: tuple[int, int, int], day: date) -> int:
"""``day``'s shutdown from a (mon_wed, thu_sun, morning_end) config."""
mon_wed, thu_sun, _ = config
return mon_wed if day.weekday() in _MON_WED else thu_sun
class GraceFloorMixin:
"""Lifts tonight's shutdown to the grace floor; 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_grace_floor(
self, log_file: Path | None, *, first_done: float | None = None
) -> bool:
"""Lift tonight's shutdown to the grace floor if it sits below it.
``first_done`` overrides the ledgers' answer (the preview script's
simulations). Returns True when the config was raised on this call.
"""
day = date.fromisoformat(today_str())
if sick_on(day):
_logger.info("Grace floor: skipped, today is a sick day")
return False
if first_done is None:
first_done = first_done_at(day, log_file, rest_day=is_rest_day(day))
floor = grace_for(first_done, day)
if floor is None:
return False
with bonus_lock(GRACE_STATE_FILE):
config = self._read_shutdown_config()
if config is None:
_logger.warning("Grace floor: shutdown config unreadable; not applied")
return False
current = tonight_minutes(config, day)
if current >= floor:
return False
mon_wed, thu_sun, morning = config
if not self._write_shutdown_config(
max(mon_wed, floor), max(thu_sun, floor), morning, restore=True
):
_logger.warning("Grace floor: failed to write shutdown config")
return False
lift = _load_lift(GRACE_STATE_FILE, today_str()) + floor - current
save_lift(lift, GRACE_STATE_FILE)
_logger.info(
"Grace floor: shutdown %s -> %s (first task done + %d min).",
hhmm(current),
hhmm(floor),
GRACE_MINUTES,
)
return True