mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
workout-locker.timer (added in e5db254) re-runs the full lock-decision
chain every 30 minutes, but is_relaxed_day() is a pure date check with
no memory of an earlier same-day dismissal. Clicking "Skip — No
Penalty" only closed the window without persisting anything, so the
next tick re-derived reason=relaxed_day and rebuilt the same popup.
Now the skip button writes a relaxed_day_skip marker to the log
(excluded from weekly counting and sync, same treatment as heat_skip),
and the startup chain checks for it before re-entering the relaxed-day
branch. Also renames workout_log.json to log.json throughout the repo,
tests, docs, and Dart comments, since the fix touches that file anyway.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qH2hJ6mvCJd3ZXpAJSaF7
159 lines
6.2 KiB
Python
159 lines
6.2 KiB
Python
"""Mixin: workout log persistence (read/write log.json)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from gatelock.log_integrity import compute_entry_hmac
|
|
|
|
from screen_locker import _compliance_state
|
|
from screen_locker._constants import SCHEDULED_SKIPS_FILE
|
|
from screen_locker._log_io import load_workout_log
|
|
from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE, manual_sync_record_id
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RecordResult:
|
|
"""Outcome of appending a workout entry to the day-keyed log.
|
|
|
|
``appended`` is ``False`` when an entry with the same ``workout_id`` was
|
|
already present for that day — the append was a no-op, so the caller must
|
|
apply NO new credit (re-verifying the same workout is idempotent).
|
|
``prior_entries`` is the day's entries *before* this append, which the
|
|
credit path uses to tell a first-of-day workout from an additional one.
|
|
"""
|
|
|
|
appended: bool
|
|
prior_entries: list[dict] = field(default_factory=list)
|
|
|
|
|
|
def _derive_workout_id(date: str, workout_data: Mapping[str, object]) -> str | None:
|
|
"""Return a stable dedup id for ``workout_data`` on ``date``.
|
|
|
|
An explicit ``workout_id`` in ``workout_data`` wins (used by manual-sync
|
|
ingestion, which carries the wire record id). Otherwise a manual workout
|
|
reuses :func:`manual_sync_record_id` so a locally-logged manual and the
|
|
same one synced back share an id; every other type keys on
|
|
``"{type}:{date}"`` — idempotent per day, which matches the "one verified
|
|
check per day" reality of the live phone/RunnerUp verifiers.
|
|
"""
|
|
explicit = workout_data.get("workout_id")
|
|
if explicit:
|
|
return str(explicit)
|
|
wtype = workout_data.get("type")
|
|
if not wtype:
|
|
return None
|
|
if wtype == MANUAL_WORKOUT_TYPE:
|
|
start = str(workout_data.get("start_time", "")).strip()
|
|
if start:
|
|
return manual_sync_record_id(date, start)
|
|
return f"{wtype}:{date}"
|
|
|
|
|
|
def _entry_workout_id(date: str, entry: Mapping[str, object]) -> str | None:
|
|
"""Return an already-logged entry's dedup id, deriving it if it has none.
|
|
|
|
Entries written before ``workout_id`` existed carry no id. Comparing only
|
|
the stored field would treat them as un-matchable, so the SAME workout
|
|
syncing back from another device would append a duplicate — which is
|
|
exactly what happened to a pre-migration manual workout once the PC began
|
|
publishing its history. Deriving the id the same way the writer would have
|
|
makes a legacy entry match its own synced copy.
|
|
"""
|
|
stored = entry.get("workout_id")
|
|
if stored:
|
|
return str(stored)
|
|
workout_data = entry.get("workout_data", {})
|
|
if not isinstance(workout_data, dict):
|
|
return None
|
|
return _derive_workout_id(date, workout_data)
|
|
|
|
|
|
def write_signed_entry(
|
|
log_file: Path, date: str, workout_data: Mapping[str, object]
|
|
) -> RecordResult:
|
|
"""Append one HMAC-signed workout entry for ``date``, deduped by workout_id.
|
|
|
|
The single write chokepoint. Shared by the live save path (today's
|
|
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
|
|
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.
|
|
|
|
Returns a :class:`RecordResult` so the credit path can tell an appended
|
|
workout from a duplicate and a first-of-day workout from an additional one.
|
|
"""
|
|
logs = load_workout_log(log_file)
|
|
entries = logs.setdefault(date, [])
|
|
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
|
|
):
|
|
return RecordResult(appended=False, prior_entries=prior)
|
|
|
|
entry: dict[str, object] = {
|
|
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
|
"workout_data": workout_data,
|
|
}
|
|
if workout_id is not None:
|
|
entry["workout_id"] = workout_id
|
|
signature = compute_entry_hmac(entry)
|
|
if signature is not None:
|
|
entry["hmac"] = signature
|
|
else:
|
|
_logger.warning("HMAC key unavailable — saving unsigned entry")
|
|
entries.append(entry)
|
|
try:
|
|
with log_file.open("w") as f:
|
|
json.dump(logs, f, indent=2)
|
|
except OSError as e:
|
|
_logger.warning("Could not save workout log: %s", e)
|
|
return RecordResult(appended=True, prior_entries=prior)
|
|
|
|
|
|
class LogMixin:
|
|
"""Handles reading and writing log.json for the ScreenLocker.
|
|
|
|
``log_file``/``workout_data`` are declared here (not assigned) so mypy
|
|
knows their types on any composing class without needing
|
|
``type: ignore[attr-defined]`` on every access — the real values are set
|
|
by ``ScreenLocker.__init__``.
|
|
"""
|
|
|
|
log_file: Path
|
|
workout_data: Mapping[str, object]
|
|
|
|
def has_logged_today(self) -> bool:
|
|
"""Check if workout has been logged today with valid HMAC."""
|
|
return _compliance_state.has_logged_today(self.log_file)
|
|
|
|
def _is_scheduled_skip_today(self) -> bool:
|
|
"""Return True if today's date is listed in the scheduled skips file."""
|
|
return _compliance_state.is_scheduled_skip_today(SCHEDULED_SKIPS_FILE)
|
|
|
|
def save_workout_log(self) -> RecordResult:
|
|
"""Append today's workout to the log (HMAC-signed) and report the result.
|
|
|
|
Returns the :class:`RecordResult` from the write chokepoint so callers
|
|
(notably :meth:`WorkoutCreditMixin._apply_workout_credit`) can apply
|
|
credit only for a genuinely new workout and scale it by whether today
|
|
already had one.
|
|
"""
|
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
return write_signed_entry(self.log_file, today, self.workout_data)
|