"""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._night_lockdown import tonights_lockdown from screen_locker._sick import workout_standdown_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 = workout_standdown_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(), evening_lockdown=tonights_lockdown(instant) is not None, 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(), )