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

186 lines
6.3 KiB
Python

"""Read-only status snapshot: today, this week, shutdown projection, budgets.
Every function in this module only reads files already on disk — no ADB, no
sudo, no network, no writes. ``_status.py`` stays untouched (it's an
existing, tested, deliberately *mutating* CLI used by a human running
``--status`` who already expects that); this module is the new, genuinely
side-effect-free layer that both the i3blocks summary line and the Tkinter
status view are built on.
"""
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from screen_locker import _manual_workout
from screen_locker._compliance_state import explain_lock_decision
from screen_locker._status_projection import (
_shutdown_projection,
format_summary_line,
)
from screen_locker._status_types import (
DayStatus,
ManualWorkoutBudgetStatus,
ShutdownProjection,
ShutdownProjectionDay,
StatusSnapshot,
WeeklySummary,
)
__all__ = [
"DayStatus",
"ManualWorkoutBudgetStatus",
"ShutdownProjection",
"ShutdownProjectionDay",
"StatusSnapshot",
"WeeklySummary",
"format_summary_line",
"gather_status",
]
from screen_locker._constants import (
EARLY_BIRD_PENDING_FILE,
EXTRA_BENEFITS_FILE,
MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
SHUTDOWN_CONFIG_FILE,
)
from screen_locker._extra_benefits import (
current_streak,
has_extended_early_bird,
)
from screen_locker._log_io import load_workout_log
from screen_locker._morning_session import has_workout_skip_today
from screen_locker._sick import sick_dates
from screen_locker._weekly_check import (
COUNTED_WORKOUT_TYPES,
WEEKLY_WORKOUT_MINIMUM,
count_day_credits,
is_relaxed_day,
)
_DEFAULT_LOG_FILE = Path(__file__).resolve().parent / "log.json"
def _day_status(
day_date: date, entries: list[dict], sick_days: frozenset[date]
) -> DayStatus:
"""Build a :class:`DayStatus` for one calendar day from its entry list.
``day_count`` comes from
:func:`~screen_locker._weekly_check.count_day_credits`, the same rule
enforcement uses, so the week total sums to
:func:`~screen_locker._weekly_check.count_weekly_workouts` by construction.
It used to re-derive the rule as ``verified + (1 if has_manual else 0)``,
which disagreed on any day with two manual workouts -- and since this value
feeds the status view's ``weekly_minimum_met`` explanation, the window could
explain a different decision than the locker actually made.
"""
iso = day_date.isoformat()
label = day_date.strftime("%a %b %d")
types = tuple(
str(e.get("workout_data", {}).get("type", ""))
for e in entries
if isinstance(e, dict)
)
sources = [
str(e.get("workout_data", {}).get("source", ""))
for e in entries
if isinstance(e, dict)
]
return DayStatus(
date=iso,
label=label,
entry_types=tuple(t for t in types if t),
source=" · ".join(s for s in sources if s),
counted=any(t in COUNTED_WORKOUT_TYPES for t in types),
day_count=count_day_credits(iso, [e for e in entries if isinstance(e, dict)]),
is_sick_day=day_date in sick_days,
)
def _week_days(log_file: Path, *, today_local: datetime) -> tuple[DayStatus, ...]:
"""Return this ISO week's days from Monday through today."""
log_data = load_workout_log(log_file)
sick_days = sick_dates()
today_date = today_local.date()
monday = today_date - timedelta(days=today_date.weekday())
days: list[DayStatus] = []
day = monday
while day <= today_date:
days.append(_day_status(day, log_data.get(day.isoformat(), []), sick_days))
day += timedelta(days=1)
return tuple(days)
def _weekly_summary(days: tuple[DayStatus, ...]) -> WeeklySummary:
"""Summarize a week's workout count against the weekly minimum."""
counted = sum(d.day_count for d in days)
return WeeklySummary(
days=days,
counted_count=counted,
minimum=WEEKLY_WORKOUT_MINIMUM,
remaining=max(0, WEEKLY_WORKOUT_MINIMUM - counted),
extra=max(0, counted - WEEKLY_WORKOUT_MINIMUM),
)
def _manual_workout_budget_status(
log_file: Path, *, today: str
) -> ManualWorkoutBudgetStatus:
"""Summarize rolling manual-workout budget usage."""
return ManualWorkoutBudgetStatus(
used_7d=_manual_workout.count_in_window(log_file, 7, today=today),
budget_7d=MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
used_30d=_manual_workout.count_in_window(log_file, 30, today=today),
budget_30d=MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
exhausted=_manual_workout.is_budget_exhausted(log_file, today=today),
)
def gather_status(
*,
log_file: Path = _DEFAULT_LOG_FILE,
extra_benefits_file: Path = EXTRA_BENEFITS_FILE,
shutdown_config_file: Path = SHUTDOWN_CONFIG_FILE,
early_bird_pending_file: Path = EARLY_BIRD_PENDING_FILE,
now: datetime | None = None,
) -> StatusSnapshot:
"""Gather a full :class:`StatusSnapshot` from on-disk state only.
Never touches ADB, sudo, or the network — safe to call on every i3blocks
tick and every status-window open/refresh.
"""
instant = now if now is not None else datetime.now(tz=UTC)
today_local = instant.astimezone()
today_str = today_local.date().isoformat()
days = _week_days(log_file, today_local=today_local)
week = _weekly_summary(days)
early_bird_extended = has_extended_early_bird(
extra_benefits_file, today=today_local
)
lock_explanation = explain_lock_decision(
log_file=log_file,
early_bird_pending_file=early_bird_pending_file,
weekly_minimum_met=week.counted_count >= WEEKLY_WORKOUT_MINIMUM,
relaxed_day=is_relaxed_day(today=today_local),
wake_skip=has_workout_skip_today(),
now=instant,
)
return StatusSnapshot(
today=days[-1],
week=week,
streak=current_streak(extra_benefits_file),
early_bird_extended=early_bird_extended,
shutdown=_shutdown_projection(
shutdown_config_file=shutdown_config_file,
today_local=today_local,
),
lock_explanation=lock_explanation,
manual_workout_budget=_manual_workout_budget_status(log_file, today=today_str),
generated_at=instant.isoformat(),
)