screen-locker/screen_locker/_sick_budget.py
Krzysztof kuhy Rudnicki 967e3cd322 Key every workout day on the LOCAL calendar day, not UTC
A manual workout logged at 00:02 CEST on 2026-09-13 was filed under
2026-09-12: every day-key derivation used datetime.now(tz=UTC), and at
that instant UTC was still yesterday. The 09:18 lock check looked for
2026-09-13, found nothing, and locked the PC — for a workout that was on
disk. The budget counter (a trailing window) counted it fine, which is
why the status showed 1/2 used while the locker still locked. Any log
between local midnight and 02:00 CEST hit this; it had happened before.

- New screen_locker/_day.py: today_str() is the one definition of "what
  day is it" — the local calendar day the user sees on the clock, which
  is also what the phone app, RunnerUp verification, the status window
  and the freedays pool already used. All 14 UTC sites (log writer, lock
  predicates, explain chain, early-bird, wake state, shutdown/sick state,
  sick budget, manual CLI, phone verification) now go through it, writers
  and readers together so no new midnight window opens between them.
- write_signed_entry dedups by workout_id across ALL days: a workout id is
  globally unique, and an entry moved to its correct day must not let its
  synced twin (still stamped with the old day) re-ingest as a duplicate.
- scripts/rekey_log_entry.py: move a wrongly-filed entry to its real day
  (the HMAC never covered the day key, so it still verifies).
- test_local_day_key.py freezes the clock at the exact failing instant.

Replayed the real timeline (log at 00:02:35, evaluate at 09:18:53, weekly
3/5) against HEAD in a throwaway worktree vs this tree:
  HEAD  -> logged under 2026-09-12, LOCK
  fixed -> logged under 2026-09-13, no lock (already_logged)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcU5dCcUNHMAMzUNChdrrJ
2026-09-13 09:50:06 +02:00

132 lines
4.1 KiB
Python

"""The sick-day record and the rolling budget computed from it.
Split out of :mod:`screen_locker._sick_tracker` to keep every file under the
250-line cap. Re-exported from there, so callers and their patch targets are
unchanged.
Everything here is pure: it reads a :class:`SickHistory` already in memory.
Loading and saving that history stays in ``_sick_tracker``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
import logging
from typing import Any
from screen_locker._constants import (
SICK_BUDGET_PER_7_DAYS,
SICK_BUDGET_PER_30_DAYS,
SICK_BUDGET_PER_90_DAYS,
SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
SICK_LOCKOUT_SECONDS,
)
from screen_locker._day import today_str
_logger = logging.getLogger(__name__)
@dataclass
class SickHistory:
"""Persistent sick-day bookkeeping."""
sick_days: list[str] = field(default_factory=list)
debt: int = 0
commitments: dict[str, bool] = field(default_factory=dict)
broken_commitments: list[str] = field(default_factory=list)
justifications: list[dict[str, Any]] = field(default_factory=list)
# Sick days later disproved by a workout the locker could not read at the
# time. Kept beside the justifications rather than folded into them: those
# entries are HMAC-signed over the whole dict, so annotating one in place
# would break verification and look like tampering.
revocations: list[dict[str, Any]] = field(default_factory=list)
def _today_iso() -> str:
"""Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``)."""
return today_str()
def _parse_iso(date_str: str) -> datetime | None:
"""Parse ``YYYY-MM-DD`` into a UTC datetime, or return None."""
try:
return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC)
except ValueError as exc:
_logger.warning(
"Sick history holds an unparsable date %r (%s) — that entry is "
"IGNORED when counting sick days",
date_str,
exc,
)
return None
def is_sick_day(history: SickHistory, *, today: str | None = None) -> bool:
"""Return True if today is recorded as a sick day."""
today_str = today or _today_iso()
return today_str in history.sick_days
def count_in_window(
history: SickHistory,
days: int,
*,
today: str | None = None,
) -> int:
"""Return how many ``sick_days`` fall in the trailing ``days`` window."""
today_str = today or _today_iso()
today_dt = _parse_iso(today_str)
if today_dt is None:
return 0
cutoff = today_dt - timedelta(days=days)
count = 0
for entry in history.sick_days:
parsed = _parse_iso(entry)
if parsed is None:
continue
if cutoff < parsed <= today_dt:
count += 1
return count
def is_budget_exhausted(
history: SickHistory,
*,
today: str | None = None,
) -> bool:
"""Return True if any rolling window has reached its sick budget."""
return (
count_in_window(history, 7, today=today) >= SICK_BUDGET_PER_7_DAYS
or count_in_window(history, 30, today=today) >= SICK_BUDGET_PER_30_DAYS
or count_in_window(history, 90, today=today) >= SICK_BUDGET_PER_90_DAYS
)
def compute_lockout_seconds(
history: SickHistory,
*,
today: str | None = None,
) -> int:
"""Escalating sick countdown: ``base * 2 ** recent_count_in_30d``."""
recent = count_in_window(history, 30, today=today)
multiplier = SICK_LOCKOUT_MULTIPLIER_PER_RECENT**recent
return SICK_LOCKOUT_SECONDS * multiplier
def budget_summary(
history: SickHistory,
*,
today: str | None = None,
) -> str:
"""One-line UI summary string for budget + debt."""
week = count_in_window(history, 7, today=today)
month = count_in_window(history, 30, today=today)
quarter = count_in_window(history, 90, today=today)
return (
f"Sick: {week}/{SICK_BUDGET_PER_7_DAYS}w · "
f"{month}/{SICK_BUDGET_PER_30_DAYS}m · "
f"{quarter}/{SICK_BUDGET_PER_90_DAYS}q · "
f"Debt: {history.debt}"
)