"""Shutdown projection and the one-line i3blocks summary. Split out of :mod:`screen_locker._status_data` to keep every file under the 250-line cap. ``format_summary_line`` is re-exported from there, so ``status_view`` and the i3blocks CLI are unaffected. Projects what time the PC will shut down on each remaining day of the week, and renders the compact status line. """ from __future__ import annotations from datetime import date, datetime, timedelta from typing import TYPE_CHECKING from screen_locker._earned import hhmm from screen_locker._shutdown import read_shutdown_config from screen_locker._shutdown_base import base_minutes from screen_locker._status_types import ShutdownProjection, ShutdownProjectionDay if TYPE_CHECKING: from pathlib import Path from screen_locker._status_types import StatusSnapshot _RELAXED_DAY_EXPLANATION = ( "Shutdown time and the screen lock are two separate systems: the lock " "decides whether you must log a workout right now; shutdown time only " "controls how late the PC stays on tonight." ) _WEEKDAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") _MON_WED_WEEKDAYS = frozenset({0, 1, 2}) def _week_rows(monday: date, *, speculative: bool) -> tuple[ShutdownProjectionDay, ...]: """Build 7 labeled rows, Mon-Sun from ``monday``, each on its own day's base. Each row asks ``base_minutes`` for its own date, so a registry cut dated mid-week (or next week) shows on the day it lands, not from today's base. """ return tuple( ShutdownProjectionDay( label=label, minutes=base_minutes(monday + timedelta(days=weekday)), speculative=speculative, ) for weekday, label in enumerate(_WEEKDAY_LABELS) ) def _shutdown_projection( *, shutdown_config_file: Path, today_local: datetime, ) -> ShutdownProjection: """Build the shutdown-time projection: tonight, rest of week, next week. Every row is the plain per-day base: no week carries a banked shutdown bonus (dropped 2026-10-10). """ tonight = read_shutdown_config(shutdown_config_file) monday = today_local.date() - timedelta(days=today_local.weekday()) rest_of_week = _week_rows(monday, speculative=False) next_week_preview = _week_rows(monday + timedelta(weeks=1), speculative=True) return ShutdownProjection( tonight=tonight, rest_of_week=rest_of_week, next_week_preview=next_week_preview, explanation=_RELAXED_DAY_EXPLANATION, ) def _tonight_minutes(snapshot: StatusSnapshot) -> int | None: """Pick the shutdown band (Mon-Wed vs Thu-Sun) matching today, in minutes. Uses ``snapshot.generated_at`` (not a fresh ``datetime.now()``) so the summary line is a pure function of the snapshot it was handed. """ if snapshot.shutdown.tonight is None: return None mon_wed, thu_sun, _morning = snapshot.shutdown.tonight generated_weekday = ( datetime.fromisoformat(snapshot.generated_at).astimezone().weekday() ) return mon_wed if generated_weekday in _MON_WED_WEEKDAYS else thu_sun def format_summary_line(snapshot: StatusSnapshot) -> str: """Cheap one-liner for the i3blocks summary (e.g. before/after a click).""" week = snapshot.week mark = "✓" if week.remaining == 0 else "…" tonight = _tonight_minutes(snapshot) tonight_str = hhmm(tonight) if tonight is not None else "?" sick = snapshot.sick_budget return ( f"{mark} {week.counted_count}/{week.minimum} workouts · " f"{tonight_str} tonight · sick {sick.used_7d}/{sick.budget_7d}" ) def compliance_state_word(snapshot: StatusSnapshot) -> str: """One-word compliance state for the tray icon: ``ok`` / ``warn`` / ``lock``. ``lock`` — the lock would fire right now (no skip condition applies). ``warn`` — not locked, but this week's minimum isn't met yet. ``ok`` — not locked and the weekly minimum is already met. """ if snapshot.lock_explanation.fired: return "lock" if snapshot.week.remaining > 0: return "warn" return "ok"