mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:47:14 +01:00
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
230 lines
7.9 KiB
Python
230 lines
7.9 KiB
Python
"""Pure, read-only predicates mirroring the lock-decision chain.
|
|
|
|
Single source of truth for the leaf checks walked by
|
|
``screen_lock.ScreenLocker._check_non_verify_exits`` and
|
|
``_auto_upgrade.AutoUpgradeMixin._check_today_state_exits``. The existing
|
|
mixins delegate their read-only checks to the functions here; this module
|
|
never touches ADB, sudo, subprocess, or the network, and never writes to
|
|
disk.
|
|
|
|
``explain_lock_decision`` re-derives the same ordered chain for the status
|
|
view. One branch is a deliberate simplification: when an early-bird pending
|
|
marker has expired, the real code attempts a live phone/RunnerUp
|
|
auto-upgrade whose outcome can't be known without ADB. Rather than guess,
|
|
this module always continues the trace as if that attempt had *not yet
|
|
resolved* (mirroring the real code's "auto-upgrade failed" fallthrough) and
|
|
surfaces the pending opportunity separately via ``AutoUpgradeOpportunity``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING
|
|
|
|
from screen_locker._compliance_predicates import (
|
|
has_logged_today,
|
|
is_early_bird_pending,
|
|
is_relaxed_day_skipped_today,
|
|
is_scheduled_skip_today,
|
|
is_sick_day_today,
|
|
)
|
|
from screen_locker._compliance_trace import (
|
|
AutoUpgradeOpportunity,
|
|
LockExplanation,
|
|
PredicateResult,
|
|
StageContext,
|
|
_stage,
|
|
describe_auto_upgrade_opportunity,
|
|
describe_degraded_sources,
|
|
)
|
|
from screen_locker._day import today_str as local_day
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
|
|
from screen_locker._degraded_sources import DegradedSource
|
|
|
|
__all__ = [
|
|
"AutoUpgradeOpportunity",
|
|
"LockExplanation",
|
|
"PredicateResult",
|
|
"describe_auto_upgrade_opportunity",
|
|
"explain_lock_decision",
|
|
"has_logged_today",
|
|
"is_early_bird_pending",
|
|
"is_relaxed_day_skipped_today",
|
|
"is_scheduled_skip_today",
|
|
"is_sick_day_today",
|
|
]
|
|
|
|
|
|
def explain_lock_decision(
|
|
*,
|
|
log_file: Path,
|
|
early_bird_pending_file: Path,
|
|
weekly_minimum_met: bool,
|
|
relaxed_day: bool,
|
|
wake_skip: bool = False,
|
|
now: datetime | None = None,
|
|
degraded_sources: Sequence[DegradedSource] = (),
|
|
) -> LockExplanation:
|
|
"""Re-derive the lock-decision chain without ADB, sudo, or writes.
|
|
|
|
Mirrors ``_check_non_verify_exits`` / ``_check_today_state_exits`` in
|
|
order. Heat-skip is never evaluated (it needs a live ``wttr.in`` call) —
|
|
reported via ``heat_skip_evaluated=False``, not guessed.
|
|
"""
|
|
instant = now if now is not None else datetime.now(tz=UTC)
|
|
today_str = local_day(instant)
|
|
local_dt = instant.astimezone()
|
|
|
|
scheduled = is_scheduled_skip_today(local_dt.date())
|
|
pending = is_early_bird_pending(early_bird_pending_file, today=today_str)
|
|
# The early-bird window IS the wake-alarm carrot (EarlyBirdMixin).
|
|
window_open = wake_skip
|
|
expired = pending and not window_open
|
|
early_bird_open_and_pending = pending and window_open
|
|
sick_today = is_sick_day_today(local_dt.date())
|
|
logged = has_logged_today(log_file, today=today_str)
|
|
relaxed_day_already_skipped = relaxed_day and is_relaxed_day_skipped_today(
|
|
log_file, today=today_str
|
|
)
|
|
|
|
auto_upgrade = describe_auto_upgrade_opportunity(
|
|
early_bird_pending=pending,
|
|
early_bird_window_open=window_open,
|
|
)
|
|
|
|
trace: list[PredicateResult] = []
|
|
stage_context = StageContext(trace, auto_upgrade, bool(degraded_sources))
|
|
|
|
def _check(
|
|
name: str,
|
|
*,
|
|
fired: bool,
|
|
reason_true: str,
|
|
reason_false: str,
|
|
terminal_reason: str | None = None,
|
|
) -> LockExplanation | None:
|
|
"""Record one trace step; return an explanation if it terminates the chain."""
|
|
reason = reason_true if fired else reason_false
|
|
trace.append(PredicateResult(name, fired, reason))
|
|
if fired and terminal_reason is not None:
|
|
return _stage(
|
|
fired=False,
|
|
stage=name,
|
|
reason=terminal_reason,
|
|
context=stage_context,
|
|
)
|
|
return None
|
|
|
|
result = _check(
|
|
"scheduled_skip",
|
|
fired=scheduled,
|
|
reason_true="Today is in scheduled_skips.json (unconditional manual skip).",
|
|
reason_false="Today is not a manually scheduled skip day.",
|
|
terminal_reason="Manually scheduled skip day — no lock, no workout required.",
|
|
)
|
|
if result is not None:
|
|
return result
|
|
|
|
# Recorded but never terminal on its own: an expired pending marker still
|
|
# falls through toward a full lock, with the upgrade opportunity surfaced
|
|
# separately via `auto_upgrade` (see module docstring).
|
|
_check(
|
|
"early_bird_pending_expired",
|
|
fired=expired,
|
|
reason_true="Logged in during the early-bird window and it has since closed "
|
|
"with no workout confirmed yet.",
|
|
reason_false="No expired early-bird pending marker.",
|
|
)
|
|
|
|
result = _check(
|
|
"early_bird_window_open",
|
|
fired=early_bird_open_and_pending,
|
|
reason_true="Logged in during the early-bird window earlier today; still "
|
|
"waiting to see a workout before the window closes.",
|
|
reason_false="Not pending inside an open early-bird window.",
|
|
terminal_reason=(
|
|
"Early-bird window still open — lock skipped while waiting for a workout."
|
|
),
|
|
)
|
|
if result is not None:
|
|
return result
|
|
|
|
result = _check(
|
|
"sick_day",
|
|
fired=sick_today,
|
|
reason_true="Today is a sick day in the shared sickdays pool.",
|
|
reason_false="Today is not a sick day.",
|
|
terminal_reason="Sick day today — lock skipped.",
|
|
)
|
|
if result is not None:
|
|
return result
|
|
|
|
degraded_note = describe_degraded_sources(degraded_sources)
|
|
result = _check(
|
|
"already_logged",
|
|
fired=logged,
|
|
reason_true="A validly-signed workout is already logged for today.",
|
|
reason_false="No workout logged for today yet." + degraded_note,
|
|
terminal_reason="Workout already logged today — lock skipped.",
|
|
)
|
|
if result is not None:
|
|
return result
|
|
|
|
result = _check(
|
|
"wake_alarm_skip",
|
|
fired=wake_skip,
|
|
reason_true="The morning session earned a workout skip for now.",
|
|
reason_false="No morning-session skip in force.",
|
|
terminal_reason="Morning session earned a skip — lock skipped.",
|
|
)
|
|
if result is not None:
|
|
return result
|
|
|
|
result = _check(
|
|
"relaxed_day_already_skipped",
|
|
fired=relaxed_day_already_skipped,
|
|
reason_true="Relaxed day already dismissed via Skip — No Penalty today.",
|
|
reason_false="No same-day relaxed-day dismissal on record.",
|
|
terminal_reason="Relaxed day already skipped today — lock skipped, "
|
|
"prompt not shown again.",
|
|
)
|
|
if result is not None:
|
|
return result
|
|
|
|
result = _check(
|
|
"relaxed_day",
|
|
fired=relaxed_day,
|
|
reason_true="Today is a relaxed day (Tue/Wed/Thu).",
|
|
reason_false="Today is not a relaxed day.",
|
|
terminal_reason="Relaxed day (Tue/Wed/Thu) — workout optional, lock skipped.",
|
|
)
|
|
if result is not None:
|
|
return result
|
|
|
|
result = _check(
|
|
"weekly_minimum_met",
|
|
fired=weekly_minimum_met,
|
|
reason_true="Weekly workout minimum already reached.",
|
|
reason_false="Weekly workout minimum not yet reached.",
|
|
terminal_reason="Weekly workout minimum already met — lock skipped.",
|
|
)
|
|
if result is not None:
|
|
return result
|
|
|
|
# Named for the conclusion; "..._pending_heat_check" read as "stuck".
|
|
return _stage(
|
|
fired=True,
|
|
stage="would_lock",
|
|
reason=(
|
|
"No skip condition applies, so a locker run starting now would "
|
|
"lock. This page re-derives the decision only: no live "
|
|
"temperature is checked, and a run may or may not be happening "
|
|
"right now (see Health)." + degraded_note
|
|
),
|
|
context=stage_context,
|
|
)
|