mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:47:37 +01:00
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
This commit is contained in:
parent
9b39196671
commit
967e3cd322
@ -14,7 +14,6 @@ rather than at the old ``_compliance_state`` path.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
@ -27,6 +26,7 @@ from screen_locker._constants import (
|
||||
EARLY_BIRD_END_MINUTE,
|
||||
EARLY_BIRD_START_HOUR,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._sick_tracker import is_sick_day as _is_sick_day
|
||||
from screen_locker._weekly_check import RELAXED_DAY_SKIP_TYPE
|
||||
@ -41,8 +41,8 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _today_str() -> str:
|
||||
"""Return today's date as ``YYYY-MM-DD`` in UTC."""
|
||||
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
"""Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``)."""
|
||||
return today_str()
|
||||
|
||||
|
||||
def is_scheduled_skip_today(today: date | None = None) -> bool:
|
||||
|
||||
@ -38,6 +38,7 @@ from screen_locker._compliance_trace import (
|
||||
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
|
||||
@ -79,7 +80,7 @@ def explain_lock_decision(
|
||||
reported via ``heat_skip_evaluated=False``, not guessed.
|
||||
"""
|
||||
instant = now if now is not None else datetime.now(tz=UTC)
|
||||
today_str = instant.astimezone(UTC).strftime("%Y-%m-%d")
|
||||
today_str = local_day(instant)
|
||||
local_dt = instant.astimezone()
|
||||
local_minutes = local_dt.hour * 60 + local_dt.minute
|
||||
|
||||
|
||||
32
screen_locker/_day.py
Normal file
32
screen_locker/_day.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""The one definition of "what day is it" for every day-keyed file.
|
||||
|
||||
``log.json``, ``sick_day_state.json``, ``early_bird_pending.json``,
|
||||
``wake_state.json`` and the sick history are all keyed by ``YYYY-MM-DD``.
|
||||
Until 2026-09-13 each writer and reader derived that key on its own, and
|
||||
most of them used ``datetime.now(tz=UTC)`` while the lock window, the phone
|
||||
app, RunnerUp verification and the status window used LOCAL time. In CEST
|
||||
that is a two-hour window after local midnight in which a workout logged
|
||||
"today" was filed under yesterday — the lock check then looked for today,
|
||||
found nothing, and locked the PC for a workout that was on disk.
|
||||
|
||||
The day is the **local calendar day**: it is what the user sees on the
|
||||
clock when they log, what the lock screen names when it says "no workout
|
||||
for today", and what the phone already writes. Every module goes through
|
||||
:func:`today_str` so the two can never disagree again; tests freeze the
|
||||
clock by patching ``screen_locker._day.datetime``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
def today_str(now: datetime | None = None) -> str:
|
||||
"""Return the local calendar day of *now* (default: this instant) as ``YYYY-MM-DD``.
|
||||
|
||||
*now* may be in any timezone; it is converted to the machine's local zone
|
||||
first, which is the only conversion that turns ``22:02Z`` on the 12th
|
||||
into the 13th the user actually logged on.
|
||||
"""
|
||||
instant = now if now is not None else datetime.now(tz=UTC)
|
||||
return instant.astimezone().strftime("%Y-%m-%d")
|
||||
@ -23,14 +23,15 @@ from screen_locker._constants import (
|
||||
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 date as YYYY-MM-DD in UTC."""
|
||||
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
"""Return today's LOCAL date as YYYY-MM-DD (see ``screen_locker._day``)."""
|
||||
return today_str()
|
||||
|
||||
|
||||
class EarlyBirdMixin:
|
||||
|
||||
@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
|
||||
from gatelock.log_integrity import compute_entry_hmac
|
||||
|
||||
from screen_locker import _compliance_state
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE, manual_sync_record_id
|
||||
|
||||
@ -78,6 +79,22 @@ def _entry_workout_id(date: str, entry: Mapping[str, object]) -> str | None:
|
||||
return _derive_workout_id(date, workout_data)
|
||||
|
||||
|
||||
def _is_logged_anywhere(logs: dict[str, list[dict]], workout_id: str) -> bool:
|
||||
"""True if any day already holds an entry with this ``workout_id``.
|
||||
|
||||
Searched across ALL days, not just the one being written: a workout id is
|
||||
globally unique (it embeds its own date), and a copy of the same workout
|
||||
can arrive keyed on a different day — e.g. an entry moved to the local day
|
||||
it was really logged on, then its synced twin coming back stamped with
|
||||
the old UTC day. Per-day dedup would append that twin as a duplicate.
|
||||
"""
|
||||
return any(
|
||||
_entry_workout_id(day, e) == workout_id
|
||||
for day, entries in logs.items()
|
||||
for e in entries
|
||||
)
|
||||
|
||||
|
||||
def write_signed_entry(
|
||||
log_file: Path, date: str, workout_data: Mapping[str, object]
|
||||
) -> RecordResult:
|
||||
@ -87,7 +104,7 @@ def write_signed_entry(
|
||||
workout) and manual-sync ingestion, which files a synced workout under its
|
||||
OWN date so the weekly count places it in the right ISO week. The log is
|
||||
day-keyed but now holds MULTIPLE entries per day: this APPENDS rather than
|
||||
overwrites. If an entry with the same ``workout_id`` already exists for the
|
||||
overwrites. If an entry with the same ``workout_id`` already exists on ANY
|
||||
day, it is a no-op — the log itself is the idempotency store, so
|
||||
re-verifying the same workout never double-records. Every write normalizes
|
||||
the whole file to the list shape, progressively migrating legacy entries.
|
||||
@ -100,9 +117,7 @@ def write_signed_entry(
|
||||
prior = list(entries)
|
||||
|
||||
workout_id = _derive_workout_id(date, workout_data)
|
||||
if workout_id is not None and any(
|
||||
_entry_workout_id(date, e) == workout_id for e in entries
|
||||
):
|
||||
if workout_id is not None and _is_logged_anywhere(logs, workout_id):
|
||||
return RecordResult(appended=False, prior_entries=prior)
|
||||
|
||||
entry: dict[str, object] = {
|
||||
@ -153,5 +168,5 @@ class LogMixin:
|
||||
credit only for a genuinely new workout and scale it by whether today
|
||||
already had one.
|
||||
"""
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = today_str()
|
||||
return write_signed_entry(self.log_file, today, self.workout_data)
|
||||
|
||||
@ -18,10 +18,10 @@ that can drift.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._manual_sync import ingest_manual_records
|
||||
from screen_locker._manual_workout import (
|
||||
SPORT_CHOICES,
|
||||
@ -129,10 +129,10 @@ def run_manual_log(log_file: Path, argv: Sequence[str]) -> int:
|
||||
add_manual_arguments(parser)
|
||||
args = parser.parse_args(list(argv))
|
||||
|
||||
# UTC, matching how the budget windows compute "today" -- a local date
|
||||
# near midnight would file the workout in a different window than the one
|
||||
# it is then counted in.
|
||||
date = args.date or datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
# The LOCAL day, like every other day-key (screen_locker._day): the old
|
||||
# UTC default filed a workout logged at 00:02 CEST under yesterday, and the
|
||||
# lock check then could not find it "today".
|
||||
date = args.date or today_str()
|
||||
draft = _draft(args)
|
||||
# Validated here as well as inside the ingest, purely so a person running
|
||||
# this by hand gets the concrete field error on stderr and a non-zero exit
|
||||
|
||||
@ -19,6 +19,7 @@ from screen_locker._constants import (
|
||||
MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
|
||||
MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._log_io import load_workout_log
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -38,8 +39,8 @@ SPORT_LABELS: dict[str, str] = {
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
"""Return today's date as ``YYYY-MM-DD`` (UTC)."""
|
||||
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
"""Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``)."""
|
||||
return today_str()
|
||||
|
||||
|
||||
def _parse_iso(date_str: str) -> datetime | None:
|
||||
|
||||
@ -8,7 +8,6 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from screen_locker._adb_transport import AdbTransportMixin
|
||||
from screen_locker._constants import (
|
||||
@ -16,6 +15,7 @@ from screen_locker._constants import (
|
||||
WORKOUT_APP_JSON_REMOTES,
|
||||
WORKOUT_DURATION_ACCEPT_MINUTES,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._http_workout_fetch import HttpWorkoutFetchMixin
|
||||
from screen_locker._log_mixin import write_signed_entry
|
||||
from screen_locker._time_check import check_clock_skew
|
||||
@ -41,7 +41,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
only fall back to a stale/older payload if no candidate is from today.
|
||||
"""
|
||||
tmp = Path(tempfile.gettempdir()) / "workout_result.json"
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
today = today_str()
|
||||
first_parsed: dict | None = None
|
||||
for remote in WORKOUT_APP_JSON_REMOTES:
|
||||
ok, _ = self._run_adb(["pull", remote, str(tmp)])
|
||||
@ -91,7 +91,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
must reflect a workout done *right now*. For crediting a workout
|
||||
found dated earlier in the week, see ``_try_fill_stronglifts_for_week``.
|
||||
"""
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
today = today_str()
|
||||
if data.get("date") != today:
|
||||
return "stale", f"Workout JSON is from {data.get('date')}, not today"
|
||||
return self._validate_json_content(data)
|
||||
|
||||
@ -7,7 +7,6 @@ Root DB fallback lives in ``_runnerup_db.py``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@ -18,6 +17,7 @@ from screen_locker._constants import (
|
||||
RUNNERUP_EXPORT_DIRS,
|
||||
WORKOUT_DURATION_ACCEPT_MINUTES,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._runnerup_backfill import RunnerUpBackfillMixin
|
||||
from screen_locker._runnerup_db import RunnerUpDbMixin
|
||||
from screen_locker._runnerup_tcx import RunnerUpTcxMixin
|
||||
@ -77,7 +77,7 @@ class RunnerUpVerificationMixin(
|
||||
fails validation), or ``None`` if no today's file exists at all
|
||||
(caller should try the root DB path instead).
|
||||
"""
|
||||
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
|
||||
today = today_str()
|
||||
exports = self._find_runnerup_exports_for_date(today)
|
||||
if not exports:
|
||||
_logger.warning(
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
@ -11,6 +10,7 @@ from screen_locker._constants import (
|
||||
ADJUST_SHUTDOWN_SCRIPT,
|
||||
SHUTDOWN_CONFIG_FILE,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._shutdown_sick_state import SickDayStateMixin
|
||||
from screen_locker._wake_alarm import WakeAlarmMixin
|
||||
|
||||
@ -76,7 +76,7 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = today_str()
|
||||
self._restore_original_config_if_needed()
|
||||
if self._sick_mode_used_today():
|
||||
_logger.warning("Sick mode already used today")
|
||||
|
||||
@ -10,11 +10,12 @@ overwrite the fresh base when it runs later in the same startup.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from screen_locker._day import today_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
@ -62,7 +63,7 @@ def reset_to_base_if_new_day(
|
||||
|
||||
Returns True if a reset was performed, False if today was already reset.
|
||||
"""
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = today_str()
|
||||
|
||||
if state_file.exists():
|
||||
try:
|
||||
|
||||
@ -10,11 +10,11 @@ earlier, so the following day can put them back.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from screen_locker._constants import SICK_DAY_STATE_FILE
|
||||
from screen_locker._day import today_str
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@ -30,7 +30,7 @@ class SickDayStateMixin:
|
||||
try:
|
||||
with SICK_DAY_STATE_FILE.open() as f:
|
||||
state = json.load(f)
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = today_str()
|
||||
return state.get("date") == today
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_logger.warning(
|
||||
@ -112,7 +112,7 @@ class SickDayStateMixin:
|
||||
if loaded is None:
|
||||
return
|
||||
state_date, orig_mw, orig_ts = loaded
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
today = today_str()
|
||||
if state_date != today:
|
||||
self._write_restored_config(orig_mw, orig_ts, state_date)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
|
||||
@ -22,6 +22,7 @@ from screen_locker._constants import (
|
||||
SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
|
||||
SICK_LOCKOUT_SECONDS,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@ -43,8 +44,8 @@ class SickHistory:
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
"""Return today's date as ``YYYY-MM-DD`` (UTC)."""
|
||||
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
"""Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``)."""
|
||||
return today_str()
|
||||
|
||||
|
||||
def _parse_iso(date_str: str) -> datetime | None:
|
||||
|
||||
@ -11,20 +11,20 @@ by patching ``screen_locker._wake_state.WAKE_STATE_FILE``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from gatelock.log_integrity import verify_entry_hmac
|
||||
|
||||
from screen_locker._constants import WAKE_STATE_FILE
|
||||
from screen_locker._day import today_str
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _today_str() -> str:
|
||||
"""Return today's date as YYYY-MM-DD in UTC."""
|
||||
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
"""Return today's LOCAL date as YYYY-MM-DD (see ``screen_locker._day``)."""
|
||||
return today_str()
|
||||
|
||||
|
||||
def load_wake_state() -> dict[str, object] | None:
|
||||
|
||||
140
screen_locker/tests/test_local_day_key.py
Normal file
140
screen_locker/tests/test_local_day_key.py
Normal file
@ -0,0 +1,140 @@
|
||||
"""The workout day is the LOCAL calendar day, on every writer and reader.
|
||||
|
||||
Regression for 2026-09-13: a manual workout logged at 00:02 local (CEST,
|
||||
``22:02Z`` the day before) was filed under the previous day because every
|
||||
day-key derivation used ``datetime.now(tz=UTC)``. The lock check then looked
|
||||
for the local date, found nothing, and locked the PC — while the trailing
|
||||
window budget (which does not key on an exact date) happily counted it. Any
|
||||
log between 00:00 and 02:00 local landed on yesterday.
|
||||
|
||||
Each test freezes the clock at that exact instant through
|
||||
``screen_locker._day`` so the same mismatch can never come back on one of the
|
||||
fourteen former ``now(tz=UTC)`` sites without failing here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, tzinfo
|
||||
import json
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker import _day
|
||||
from screen_locker._compliance_predicates import has_logged_today
|
||||
from screen_locker._compliance_state import explain_lock_decision
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._log_mixin import write_signed_entry
|
||||
from screen_locker._sick_tracker import SickHistory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
# 00:02:35 on 2026-09-13 in Europe/Warsaw — the football was logged then.
|
||||
JUST_AFTER_MIDNIGHT_UTC = datetime(2026, 9, 12, 22, 2, 35, tzinfo=UTC)
|
||||
LOCAL_DAY = "2026-09-13"
|
||||
UTC_DAY = "2026-09-12"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def warsaw_after_midnight(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
"""Pin TZ to Europe/Warsaw and the clock to 00:02:35 local on the 13th."""
|
||||
monkeypatch.setenv("TZ", "Europe/Warsaw")
|
||||
time.tzset()
|
||||
|
||||
class _FrozenClock:
|
||||
"""Stands in for ``datetime`` inside ``_day``; only ``now`` is called."""
|
||||
|
||||
@staticmethod
|
||||
def now(tz: tzinfo | None = None) -> datetime:
|
||||
return JUST_AFTER_MIDNIGHT_UTC.astimezone(tz or UTC)
|
||||
|
||||
monkeypatch.setattr(_day, "datetime", _FrozenClock)
|
||||
yield
|
||||
monkeypatch.delenv("TZ")
|
||||
time.tzset()
|
||||
|
||||
|
||||
class TestTodayStr:
|
||||
"""``_day.today_str`` is the single source of "what day is it"."""
|
||||
|
||||
def test_uses_the_local_calendar_day(self, warsaw_after_midnight: None) -> None:
|
||||
assert _day.today_str() == LOCAL_DAY
|
||||
|
||||
def test_explicit_instant_is_converted_to_local(
|
||||
self, warsaw_after_midnight: None
|
||||
) -> None:
|
||||
assert _day.today_str(JUST_AFTER_MIDNIGHT_UTC) == LOCAL_DAY
|
||||
|
||||
|
||||
class TestAfterMidnightLog:
|
||||
"""A workout logged just after local midnight counts for the new day."""
|
||||
|
||||
def test_write_and_lock_check_agree(
|
||||
self, tmp_path: Path, warsaw_after_midnight: None
|
||||
) -> None:
|
||||
log_file = tmp_path / "log.json"
|
||||
write_signed_entry(
|
||||
log_file,
|
||||
_day.today_str(),
|
||||
{"type": "manual_workout", "start_time": "14:30"},
|
||||
)
|
||||
assert list(load_workout_log(log_file)) == [LOCAL_DAY]
|
||||
with patch(
|
||||
"screen_locker._compliance_predicates.verify_entry_hmac", return_value=True
|
||||
):
|
||||
assert has_logged_today(log_file) is True
|
||||
|
||||
def test_explain_sees_the_workout(
|
||||
self, tmp_path: Path, warsaw_after_midnight: None
|
||||
) -> None:
|
||||
"""The read-only decision chain keys on the same local day."""
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text(json.dumps({LOCAL_DAY: [{"workout_data": {"type": "x"}}]}))
|
||||
with patch(
|
||||
"screen_locker._compliance_predicates.verify_entry_hmac", return_value=True
|
||||
):
|
||||
result = explain_lock_decision(
|
||||
log_file=log_file,
|
||||
early_bird_pending_file=tmp_path / "early_bird_pending.json",
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=True,
|
||||
relaxed_day=False,
|
||||
now=JUST_AFTER_MIDNIGHT_UTC,
|
||||
)
|
||||
assert result.fired is False
|
||||
assert result.stage == "already_logged"
|
||||
|
||||
def test_utc_day_entry_does_not_count(
|
||||
self, tmp_path: Path, warsaw_after_midnight: None
|
||||
) -> None:
|
||||
"""An entry filed under the UTC day is yesterday's, not today's."""
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text(json.dumps({UTC_DAY: [{"workout_data": {"type": "x"}}]}))
|
||||
with patch(
|
||||
"screen_locker._compliance_predicates.verify_entry_hmac", return_value=True
|
||||
):
|
||||
assert has_logged_today(log_file) is False
|
||||
|
||||
|
||||
class TestCrossDayDedup:
|
||||
"""``write_signed_entry`` dedups by workout_id across ALL days.
|
||||
|
||||
Moving an entry to its correct day must not let its synced copy re-ingest
|
||||
under the old key: the id is globally unique, so the log is searched
|
||||
whole, not per day.
|
||||
"""
|
||||
|
||||
def test_same_workout_id_on_another_day_is_not_appended(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
log_file = tmp_path / "log.json"
|
||||
data = {"type": "manual_workout", "workout_id": "manual:2026-09-12T14:30"}
|
||||
assert write_signed_entry(log_file, LOCAL_DAY, data).appended is True
|
||||
result = write_signed_entry(log_file, UTC_DAY, data)
|
||||
assert result.appended is False
|
||||
assert UTC_DAY not in load_workout_log(log_file)
|
||||
97
scripts/rekey_log_entry.py
Executable file
97
scripts/rekey_log_entry.py
Executable file
@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""Move one ``log.json`` entry to the day it was really logged on.
|
||||
|
||||
Until 2026-09-13 the PC filed workouts under the UTC day, so anything logged
|
||||
between local midnight and 02:00 CEST landed on *yesterday*. The code now
|
||||
keys on the local day (``screen_locker._day``); this script repairs an entry
|
||||
that was filed under the wrong key before that fix.
|
||||
|
||||
The HMAC signs ``timestamp`` + ``workout_data`` + ``workout_id`` — never the
|
||||
day key — so the entry moves verbatim and still verifies. Its ``workout_id``
|
||||
is kept too: the write chokepoint dedups by id across all days, so the synced
|
||||
copy (still stamped with the old day) is recognised rather than re-ingested.
|
||||
|
||||
Reads and writes ``log.json`` directly rather than importing ``screen_locker``,
|
||||
like the other scripts here: recovery must work while the package is mid-edit.
|
||||
|
||||
Usage:
|
||||
python3 scripts/rekey_log_entry.py --workout-id manual:2026-09-12T14:30 \\
|
||||
--to 2026-09-13
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
LOG_FILE = Path(__file__).resolve().parent.parent / "screen_locker" / "log.json"
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _report(message: str) -> None:
|
||||
"""Write one line to stdout (matches the other scripts in this directory)."""
|
||||
sys.stdout.write(f"{message}\n")
|
||||
|
||||
|
||||
def rekey(log: dict, workout_id: str, to_day: str) -> str:
|
||||
"""Move the entry carrying ``workout_id`` under ``to_day`` in place.
|
||||
|
||||
Idempotent: an entry already under ``to_day`` is left alone. Raises
|
||||
``LookupError`` when the id is not in the log at all — a silent no-op
|
||||
there would let a caller revoke a sick day against nothing.
|
||||
"""
|
||||
for day, entries in log.items():
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
if entry.get("workout_id") != workout_id:
|
||||
continue
|
||||
if day == to_day:
|
||||
return f" {workout_id}: already under {to_day} — nothing to do"
|
||||
entries.remove(entry)
|
||||
if not entries:
|
||||
del log[day]
|
||||
log.setdefault(to_day, []).append(entry)
|
||||
return f" {workout_id}: moved {day} -> {to_day}"
|
||||
msg = f"{workout_id} is not in the log"
|
||||
raise LookupError(msg)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Re-key one entry and rewrite the log, or exit non-zero having changed nothing."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--workout-id", required=True, help="entry's workout_id")
|
||||
parser.add_argument("--to", required=True, help="YYYY-MM-DD to file it under")
|
||||
parser.add_argument("--log-file", default=str(LOG_FILE))
|
||||
args = parser.parse_args()
|
||||
|
||||
log_path = Path(args.log_file)
|
||||
try:
|
||||
log = json.loads(log_path.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_logger.exception("Cannot read %s", log_path)
|
||||
_report(f"ERROR: cannot read {log_path}: {exc}")
|
||||
return 1
|
||||
try:
|
||||
outcome = rekey(log, args.workout_id, args.to)
|
||||
except LookupError as exc:
|
||||
_logger.exception("Nothing changed")
|
||||
_report(f"ERROR: {exc} — nothing changed")
|
||||
return 1
|
||||
try:
|
||||
log_path.write_text(json.dumps(log, indent=2))
|
||||
except OSError as exc:
|
||||
_logger.exception("Could not write %s", log_path)
|
||||
_report(f"ERROR: could not write {log_path}: {exc}")
|
||||
return 1
|
||||
_report(outcome)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
sys.exit(main())
|
||||
Loading…
Reference in New Issue
Block a user