screen-locker/screen_locker/_grace_floor.py
Krzysztof kuhy Rudnicki 7d8daca111 sick days: one shared sickdays pool, 20:00 shutdown floor, one-tap lock button
A sick day is now a date in the shared sickdays pool (utils sickdays 0.1.0)
and nothing else. Removed: the 7/30/90-day budget, the escalating lockout
countdown, the justification form, the commitment prompt and its double
debt, the workout-debt counter, the -60 min shutdown cut and its
sick_day_state.json, the post-sick-day --verify-workout mode, the sick
auto-upgrade, scripts/revoke_sick_day.py and their tests.

- Lock screen: "I'm sick" on the retry, no-phone and both Tue-Thu screens;
  one tap -> sickdays.add_sick_day(today) -> sick floor -> best-effort
  background sickdays.sync_quietly -> unlock. Refusals (free day,
  unreadable pool) are shown and the lock stays.
- Shutdown: the reset resolves with earned_time.resolve(..., sick=True)
  (max(20:00, ladder), capped 23:00) and saves the sick lift with the grace
  lift, so later credits absorb it instead of stacking on 20:00. A day
  marked after the reset is lifted by the new _sick_floor live pass (every
  5-min tick, --sync-only, --apply-bonuses, and the tap itself).
- Workout lock and grace floor stand down on a sick day; workouts that day
  credit normally through the sync paths.
- wake-alarm workday_penalty.json is void when its issued_date or
  penalty_date is a sick day.
- Status window, i3 line, CLI --status, MCP flags and web reasons read
  sickdays.
- scripts/migrate_sick_history.py imports sick_history.json's dates via
  sickdays.import_legacy, verifies and removes the file; idempotent, run by
  install.sh.
- Pins: earned-time 0.11.0, freedays 0.4.0, sickdays 0.1.0 (tags pending);
  pre-commit 4.7.0, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 13:30:39 +02:00

203 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 gate earner's first
verified credit, flat or counted (``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, gate_earners, hhmm
from screen_locker._log_io import load_workout_log
from screen_locker._rest_day import is_rest_day
from screen_locker._sick import is_sick
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.
Every gate in force on ``day`` counts, flat or counted: the Automation
tutor's first verified block starts the floor like a LeetCode solve does.
"""
times = [first_credit_time(e, day) for e in gate_earners(day)]
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 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:
"""Today's recorded floor lift (grace or sick), 0 when none or unreadable."""
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 is_sick(day):
# The sick floor (20:00, _sick_floor) stands in for it that 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