screen-locker/screen_locker/_early_bird.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

77 lines
2.8 KiB
Python

"""Early bird window detection and pending-state helpers for ScreenLocker.
The early-bird "still waiting to see if a real workout shows up" flag is a
same-day pending marker, not a workout — it is intentionally kept out of
log.json (which is reserved for real outcomes) and instead lives in
its own self-expiring, HMAC-signed state file, mirroring the pattern used by
``_wake_state.py`` for the companion wake_alarm service.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
import logging
from gatelock.log_integrity import compute_entry_hmac
from screen_locker._compliance_state import is_early_bird_pending
from screen_locker._constants import (
EARLY_BIRD_END_HOUR,
EARLY_BIRD_END_MINUTE,
EARLY_BIRD_PENDING_FILE,
EARLY_BIRD_START_HOUR,
EXTRA_BENEFITS_FILE,
)
from screen_locker._day import today_str
from screen_locker._extra_benefits import has_extended_early_bird
_logger = logging.getLogger(__name__)
def _today_str() -> str:
"""Return today's LOCAL date as YYYY-MM-DD (see ``screen_locker._day``)."""
return today_str()
class EarlyBirdMixin:
"""Mixin providing early-bird time window checks and pending-state helpers."""
def _get_local_time_minutes(self) -> int:
"""Return current local time as minutes from midnight."""
now = datetime.now(tz=UTC).astimezone()
return now.hour * 60 + now.minute
def _is_early_bird_time(self) -> bool:
"""Return True if current local time is in the early bird window.
Normally the window closes at 08:30. When the current ISO week has an
extended early-bird reward (earned by 5+ workouts the prior week) the
window extends to 09:00.
"""
minutes = self._get_local_time_minutes()
start = EARLY_BIRD_START_HOUR * 60
if has_extended_early_bird(EXTRA_BENEFITS_FILE):
end = 9 * 60 # 09:00
else:
end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
return start <= minutes < end
def _is_early_bird_pending(self) -> bool:
"""Check if today has an unresolved early-bird pending marker."""
return is_early_bird_pending(EARLY_BIRD_PENDING_FILE)
def _save_early_bird_pending(self) -> None:
"""Save today's early-bird pending marker (self-expires tomorrow)."""
state: dict[str, object] = {"date": _today_str()}
signature = compute_entry_hmac(state)
if signature is not None:
state["hmac"] = signature
else:
_logger.warning("HMAC key unavailable — saving unsigned pending marker")
try:
with EARLY_BIRD_PENDING_FILE.open("w") as f:
json.dump(state, f, indent=2)
except OSError as exc:
_logger.warning("Could not save early-bird pending marker: %s", exc)