screen-locker/screen_locker/_shutdown.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

204 lines
6.8 KiB
Python

"""Shutdown schedule adjustment mixin for the screen locker."""
from __future__ import annotations
from datetime import date
import logging
import subprocess
from typing import TYPE_CHECKING
import earned_time
from screen_locker._constants import (
ADJUST_SHUTDOWN_SCRIPT,
SHUTDOWN_CONFIG_FILE,
)
from screen_locker._day import today_str
from screen_locker._earned import ceiling, first_minutes, hhmm, span
from screen_locker._grace_floor import GraceFloorMixin, plan_absorb, save_lift
from screen_locker._sick_floor import SickFloorMixin
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
# Every time below is minutes after midnight. The config's *_MINUTES keys are
# authoritative; a file written before the minutes migration has only the
# whole-hour *_HOUR keys, read as HOUR * 60.
_SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END")
_MINUTES_PER_HOUR = 60
# Extra bonuses may name midnight; the helper still clamps them to 23:00.
_MIDNIGHT = 24 * _MINUTES_PER_HOUR
def _parse_config(path: Path) -> dict[str, int]:
"""Every integer ``KEY=value`` line of *path*."""
parsed: dict[str, int] = {}
with path.open() as f:
for line in f:
key, sep, value = line.strip().partition("=")
if sep and value.strip().isdigit():
parsed[key] = int(value)
return parsed
def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
"""Read shutdown config from *path* as (mon_wed, thu_sun, morning_end) minutes.
Reading needs no privilege (only writing does, via
``adjust_shutdown_schedule.sh``) — safe to call from a read-only status view.
"""
if not path.exists():
_logger.warning("Config not found: %s", path)
return None
parsed = _parse_config(path)
values: list[int] = []
for name in _SHUTDOWN_CONFIG_NAMES:
if f"{name}_MINUTES" in parsed:
values.append(parsed[f"{name}_MINUTES"])
elif f"{name}_HOUR" in parsed:
values.append(parsed[f"{name}_HOUR"] * _MINUTES_PER_HOUR)
else:
_logger.warning("Shutdown config missing required values")
return None
mon_wed, thu_sun, morning_end = values
return mon_wed, thu_sun, morning_end
class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
"""Mixin providing shutdown schedule adjustment functionality."""
def _adjust_shutdown_time_later(self) -> bool:
"""Push shutdown later by the first workout's reward, capped at the ceiling.
Returns True if successful, False otherwise.
"""
day = date.fromisoformat(today_str())
bonus, lift = plan_absorb(first_minutes(earned_time.WORKOUT, day))
try:
config_values = self._read_shutdown_config()
if config_values is None:
return False
mon_wed, thu_sun, morning_end = config_values
ok = self._write_shutdown_config(
min(ceiling(day), mon_wed + bonus),
min(ceiling(day), thu_sun + bonus),
morning_end,
restore=True,
)
except (OSError, ValueError) as e:
_logger.warning("Failed to adjust shutdown time for workout: %s", e)
return False
if ok:
save_lift(lift)
return ok
def _adjust_shutdown_time_by(self, extra_minutes: int) -> bool:
"""Push shutdown later by *extra_minutes*, capped at 24:00 (midnight).
Used for earner and extra-workout bonuses. A cap of midnight works
because ``day-specific-shutdown-check.sh`` fires at 00:00 and catches it
via the morning-window condition; the helper clamps it to 23:00 anyway.
Returns True if successful, False otherwise.
"""
# Paid out of the grace lift first, so the floor is never stacked on.
moved, lift = plan_absorb(extra_minutes)
try:
config_values = self._read_shutdown_config()
if config_values is None:
return False
mw, ts, morning = config_values
ok = self._write_shutdown_config(
min(_MIDNIGHT, mw + moved),
min(_MIDNIGHT, ts + moved),
morning,
restore=True,
)
except (OSError, ValueError) as e:
_logger.warning(
"Failed to adjust shutdown time by %s: %s", span(extra_minutes), e
)
return False
if ok:
save_lift(lift)
return ok
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
"""Read shutdown config as (mon_wed, thu_sun, morning_end) minutes, or None."""
return read_shutdown_config(SHUTDOWN_CONFIG_FILE)
def _build_shutdown_cmd(
self,
mon_wed: int,
thu_sun: int,
morning: int,
*,
restore: bool,
) -> list[str]:
"""Build the shutdown adjustment command."""
cmd = ["/usr/bin/sudo", str(ADJUST_SHUTDOWN_SCRIPT)]
if restore:
cmd.append("--restore")
cmd.extend([hhmm(mon_wed), hhmm(thu_sun), hhmm(morning)])
return cmd
def _write_shutdown_config(
self,
mon_wed: int,
thu_sun: int,
morning_end: int,
*,
restore: bool = False,
) -> bool:
"""Write new shutdown config values using helper script.
Args:
mon_wed: Monday-Wednesday shutdown, minutes after midnight.
thu_sun: Thursday-Sunday shutdown, minutes after midnight.
morning_end: End of the morning window, minutes after midnight.
restore: If True, allows restoring to later times.
Returns True if successful, False otherwise.
"""
if not ADJUST_SHUTDOWN_SCRIPT.exists():
_logger.warning(
"Script not found: %s",
ADJUST_SHUTDOWN_SCRIPT,
)
return False
cmd = self._build_shutdown_cmd(
mon_wed,
thu_sun,
morning_end,
restore=restore,
)
return self._run_shutdown_cmd(cmd, mon_wed, thu_sun)
def _run_shutdown_cmd(
self,
cmd: list[str],
mon_wed: int,
thu_sun: int,
) -> bool:
"""Execute the shutdown adjustment command."""
try:
result = subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
)
except subprocess.SubprocessError as e:
_logger.warning("Failed to adjust shutdown config: %s", e)
return False
_logger.info(
"Adjusted shutdown: Mon-Wed=%s, Thu-Sun=%s. %s",
hhmm(mon_wed),
hhmm(thu_sun),
result.stdout.strip(),
)
return True