screen-locker/screen_locker/_status_data.py
Krzysztof kuhy Rudnicki aa27a8ece6
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
workout lock: do not re-arm after tonight's evening night-lockdown (gatelock 0.10.0 night_lockdown)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:32:30 +02:00

188 lines
6.4 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._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(),
)