screen-locker/screen_locker/_log_io.py
Krzysztof kuhy Rudnicki c1c73d43ba
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
Stop the relaxed-day popup from re-firing every 30 minutes after Skip
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
2026-08-18 21:55:59 +02:00

76 lines
2.6 KiB
Python

"""Single normalization point for reading ``log.json``.
The log is day-keyed and now holds MULTIPLE workouts per day. This module is
the one place that reads the raw file and normalizes both shapes into a
per-day list:
* legacy ``{date: entry}`` — the single ``entry`` is wrapped in a
one-element list, and
* new ``{date: [entry, ...]}`` — returned as-is.
Kept dependency-free (stdlib only) so any module — including the low-level
``_log_mixin`` and ``_compliance_state`` — can import it without risking an
import cycle. Every consumer reads through :func:`load_workout_log` and
iterates the per-day list rather than assuming one entry per day.
"""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
def read_raw_log(log_file: Path) -> dict:
"""Load ``log.json`` verbatim (values may be dict or list), or ``{}``.
Returns the on-disk shape unchanged. Prefer :func:`load_workout_log` for
reading; this raw form exists for the writer, which rewrites the file, and
for the one-shot migration.
"""
if not log_file.exists():
return {}
try:
with log_file.open() as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
_logger.warning(
"Could not read the workout log at %s (%s) — treating it as EMPTY, "
"so NO workouts count and the lock will fire until it is readable",
log_file,
exc,
)
return {}
if not isinstance(data, dict):
_logger.warning(
"Workout log at %s is a %s, not an object of dates — treating it as "
"EMPTY, so NO workouts count",
log_file,
type(data).__name__,
)
return {}
return data
def load_workout_log(log_file: Path) -> dict[str, list[dict]]:
"""Load ``log.json`` normalized to ``{date: [entry, ...]}``.
Accepts BOTH the legacy day-keyed shape (``{date: entry}``) and the new
multi-entry shape so old files, rolled-back writers, and mid-migration
states all read correctly. Each entry is the usual
``{timestamp, workout_data, hmac[, workout_id]}`` dict; malformed values
are dropped. Missing/corrupt file → ``{}``.
"""
normalized: dict[str, list[dict]] = {}
for date, value in read_raw_log(log_file).items():
if isinstance(value, list):
normalized[date] = [entry for entry in value if isinstance(entry, dict)]
elif isinstance(value, dict):
normalized[date] = [value]
return normalized