screen-locker/screen_locker/_status_data.py
Krzysztof kuhy Rudnicki d6e8c0973f Replace scheduled skips with the shared free-day pool
Fifth and last app onto freedays (~/utils/freedays). screen-locker is the
one that already had this idea: scheduled_skips.json was a bare list of
dates, checked first in the lock chain, that only this app could see.
That is exactly what the shared pool generalises, so the chain now asks
freedays and a day taken here also stands down diet-guard, wake-alarm,
leetcode-guard and home-guard.

Sick days are deliberately untouched. Their rolling windows (1/7d, 3/30d,
10/90d), the 120-character justification and the escalating countdown all
exist to make a sick day *hard* to take. Free days are the opposite --
declared ahead, no reason, consecutive runs allowed. Merging the two
policies would have had to ruin one of them, so only the storage of the
frictionless half moved.

This fixes a real bug in passing. is_scheduled_skip_today compared
against a UTC day while add_scheduled_skip.py wrote a local one, so a
skip added near midnight could name a date the chain never matched.
freedays owns "what is today" for both sides now, in local time, and the
disagreement cannot recur.

scripts/migrate_scheduled_skips.py moves the existing dates across; it is
idempotent and has a --dry-run. Already run here: the 8 real skips
(2026-05-19/20/21, 05-29, 05-30, 08-17, 08-22, 08-29) are in the pool and
count against 2026, leaving 27 of 35. The original file is gitignored and
was left on disk untouched.

add_scheduled_skip.py becomes a thin wrapper over freedays rather than
being deleted, because arm.sh calls it and the name is in muscle memory.
Left as-is it would have kept writing to a file nothing reads.

conftest.py went over the 250-line cap, so the two fixtures that keep the
suite off real user state moved to _real_state_fixtures.py. They belong
together and neither subsumes the other: _isolate_home covers paths
resolved from Path.home() at call time, while the freedays redirect
covers paths bound into module constants at import time, which
redirecting home afterwards cannot reach.

requirements.txt also takes pytest-randomly 5.0.0, ruff 0.16.6 and
types-requests 2.33.0.20260906, and the pylint hooks' gatelock/crdt-sync
pins move off v0.7.1/v0.9.0 -- pip refuses two direct URLs for one
distribution, so those stale pins would have made the hook environments
unresolvable rather than merely old.

1256 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:42:41 +02:00

226 lines
7.7 KiB
Python

"""Read-only status snapshot: today, this week, shutdown projection, sick budget.
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 typing import TYPE_CHECKING
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,
SickBudgetStatus,
StatusSnapshot,
WeeklySummary,
)
__all__ = [
"DayStatus",
"ManualWorkoutBudgetStatus",
"ShutdownProjection",
"ShutdownProjectionDay",
"SickBudgetStatus",
"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_BASE_FILE,
SHUTDOWN_CONFIG_FILE,
SICK_BUDGET_PER_7_DAYS,
SICK_BUDGET_PER_30_DAYS,
SICK_BUDGET_PER_90_DAYS,
)
from screen_locker._extra_benefits import (
current_streak,
has_extended_early_bird,
weekly_shutdown_bonus_hours,
)
from screen_locker._log_io import load_workout_log
from screen_locker._sick_tracker import (
count_in_window,
is_budget_exhausted,
load_history,
)
from screen_locker._wake_state import has_workout_skip_today
from screen_locker._weekly_check import (
COUNTED_WORKOUT_TYPES,
WEEKLY_WORKOUT_MINIMUM,
count_day_credits,
is_relaxed_day,
)
if TYPE_CHECKING:
from screen_locker._sick_tracker import SickHistory
_DEFAULT_LOG_FILE = Path(__file__).resolve().parent / "log.json"
def _day_status(day_date: date, entries: list[dict], sick_days: set[str]) -> 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=iso in sick_days,
)
def _week_days(
log_file: Path, sick_history: SickHistory, *, today_local: datetime
) -> tuple[DayStatus, ...]:
"""Return this ISO week's days from Monday through today."""
log_data = load_workout_log(log_file)
sick_days = set(sick_history.sick_days)
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 _sick_budget_status(history: SickHistory, *, today: str) -> SickBudgetStatus:
"""Summarize rolling sick-day budget usage."""
return SickBudgetStatus(
used_7d=count_in_window(history, 7, today=today),
budget_7d=SICK_BUDGET_PER_7_DAYS,
used_30d=count_in_window(history, 30, today=today),
budget_30d=SICK_BUDGET_PER_30_DAYS,
used_90d=count_in_window(history, 90, today=today),
budget_90d=SICK_BUDGET_PER_90_DAYS,
debt=history.debt,
exhausted=is_budget_exhausted(history, today=today),
)
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_base_file: Path = SHUTDOWN_BASE_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()
sick_history = load_history()
days = _week_days(log_file, sick_history, 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,
sick_history=sick_history,
extended_early_bird=early_bird_extended,
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,
bonus_hours_this_week=weekly_shutdown_bonus_hours(
extra_benefits_file, today=today_local
),
streak=current_streak(extra_benefits_file),
early_bird_extended=early_bird_extended,
shutdown=_shutdown_projection(
shutdown_base_file=shutdown_base_file,
shutdown_config_file=shutdown_config_file,
extra_benefits_file=extra_benefits_file,
log_file=log_file,
today_local=today_local,
),
lock_explanation=lock_explanation,
sick_budget=_sick_budget_status(sick_history, today=today_str),
manual_workout_budget=_manual_workout_budget_status(log_file, today=today_str),
generated_at=instant.isoformat(),
)