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

83 lines
2.7 KiB
Python

"""The one place screen-locker asks the shared ``sickdays`` pool.
A sick day is a date in that pool, nothing else: no budget, no countdown, no
justification and no debt. Every consumer here (lock ladder, shutdown,
status, MCP) goes through this module, so the answer cannot drift between
them and tests have one seam.
Reading never raises: an unreadable pool is logged by ``sickdays`` and reads
as *not sick* -- fail closed, the locks apply.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import threading
from typing import TYPE_CHECKING
import sickdays
if TYPE_CHECKING:
from datetime import date
_logger = logging.getLogger(__name__)
def is_sick(day: date | None = None) -> bool:
"""Whether ``day`` (default: today) is a sick day."""
return sickdays.is_sick_day(day)
def sick_dates() -> frozenset[date]:
"""Every date the pool marks sick, past and future."""
return sickdays.sick_days()
def push_in_background() -> threading.Thread:
"""Start one best-effort ``sickdays.sync_quietly`` on a background thread.
So the phone sees a lock-screen mark without waiting for the 15-minute
sickdays-sync timer. Not a daemon: the lock window closes at once and the
process lingers only until the push ends, instead of killing it mid-write.
``sync_quietly`` never raises; a failed push is retried by that timer.
"""
worker = threading.Thread(
target=sickdays.sync_quietly, name="sickdays-push", daemon=False
)
worker.start()
return worker
@dataclass(frozen=True)
class MarkResult:
"""Outcome of :func:`mark_today_sick`.
Attributes:
ok: Today is a sick day now (newly marked or already was).
message: What to show the user: a confirmation, or why it was refused.
"""
ok: bool
message: str
def mark_today_sick() -> MarkResult:
"""Mark today sick in the shared pool; every gate stands down for it.
Already sick counts as success. Any other refusal (a free day, an
unreadable pool, freedays not importable) is returned with sickdays' own
explanation, so the lock screen can show it instead of unlocking.
"""
today = sickdays.today()
try:
sickdays.add_sick_day(today)
except sickdays.AlreadySickError:
_logger.warning("%s was already a sick day; unlocking", today)
return MarkResult(ok=True, message="Today is already a sick day.")
except sickdays.SickDayError as exc:
_logger.warning("Could not mark %s as a sick day: %s", today, exc)
return MarkResult(ok=False, message=str(exc))
_logger.warning("Marked %s as a sick day from the lock screen", today)
return MarkResult(ok=True, message="Today is a sick day. Get well soon.")