mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:07:14 +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
248 lines
10 KiB
Python
248 lines
10 KiB
Python
"""Ingest phone-/PC-synced manual workouts into ``log.json``.
|
|
|
|
A manual workout logged on another device (phone form, or the PC form pushed
|
|
from elsewhere) arrives via crdt-sync as a ``kind="manual_workout"`` payload
|
|
(see :func:`screen_locker._manual_workout.build_sync_payload`). This module
|
|
turns those synced payloads into ordinary signed ``manual_workout`` entries in
|
|
``log.json`` so they count toward the weekly minimum and — via the
|
|
optional ``on_ingested`` callback — earn the same shutdown/debt reward a
|
|
live-logged workout would.
|
|
|
|
Trust model: the PC never trusts the phone's derived fields. Every synced
|
|
manual is re-validated with the same :func:`validate_manual_workout` the local
|
|
form uses, re-built with :func:`build_entry`, and only then HMAC-signed by the
|
|
PC. A phone can therefore at most add a budget-limited, honour-system
|
|
``manual_workout`` — never a ``phone_verified`` one (the session path skips
|
|
manual records, see :mod:`screen_locker._workout_sync`).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from screen_locker._log_io import load_workout_log
|
|
from screen_locker._log_mixin import write_signed_entry
|
|
from screen_locker._manual_workout import (
|
|
MANUAL_WORKOUT_SYNC_KIND,
|
|
SPORT_OTHER,
|
|
ManualWorkoutDraft,
|
|
build_entry,
|
|
is_budget_exhausted,
|
|
validate_manual_workout,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Iterable, Mapping
|
|
from pathlib import Path
|
|
|
|
OnIngestedCallback = Callable[[dict, "list[dict]"], None]
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Key under which the source sync record id is stored in the ingested
|
|
# ``workout_data`` — makes re-ingestion of the same record idempotent.
|
|
_SYNC_ID_FIELD = "sync_record_id"
|
|
|
|
# The raw user inputs every real manual workout carries. A payload holding none
|
|
# of them is a metadata stub, not a damaged workout: there is nothing to
|
|
# recover and no amount of retrying will change that.
|
|
_SUBSTANTIVE_FIELDS = frozenset(
|
|
{
|
|
"sport",
|
|
"start_time",
|
|
"end_time",
|
|
"location_name",
|
|
"transport_method",
|
|
"cost",
|
|
"rpe",
|
|
"went_well",
|
|
"to_improve",
|
|
"overall_feeling",
|
|
}
|
|
)
|
|
|
|
|
|
def is_empty_stub(payload: Mapping[str, object]) -> bool:
|
|
"""True when ``payload`` carries no workout content at all.
|
|
|
|
Observed in the wild as ``{"type", "kind", "date"}`` and nothing else --
|
|
a record the phone created without ever attaching the workout. Every sync
|
|
cycle re-reported it as malformed, so a permanent, unfixable condition
|
|
produced an unbounded stream of identical warnings (four every 15 minutes),
|
|
which is how the genuinely actionable lines got lost in the noise.
|
|
"""
|
|
return not (_SUBSTANTIVE_FIELDS & set(payload))
|
|
|
|
|
|
def _coerce_int(value: object) -> int:
|
|
"""Coerce a JSON scalar to int; raise for a non-numeric (skips the record)."""
|
|
if isinstance(value, (int, str)):
|
|
return int(value)
|
|
raise TypeError(value)
|
|
|
|
|
|
def reconstruct_draft(payload: Mapping[str, object]) -> ManualWorkoutDraft | None:
|
|
"""Rebuild a :class:`ManualWorkoutDraft` from a synced manual payload.
|
|
|
|
Returns None if a required raw field is missing or mistyped, so a malformed
|
|
record is skipped rather than crashing ingestion. Only the raw user inputs
|
|
are read back — the derived fields (``source``, ``duration_minutes``,
|
|
``type``) are recomputed by :func:`build_entry` on the PC.
|
|
"""
|
|
try:
|
|
sport = str(payload["sport"])
|
|
activity_type_other = (
|
|
str(payload.get("activity_type", "")) if sport == SPORT_OTHER else ""
|
|
)
|
|
return ManualWorkoutDraft(
|
|
sport=sport,
|
|
start_time=str(payload["start_time"]),
|
|
end_time=str(payload["end_time"]),
|
|
location_name=str(payload["location_name"]),
|
|
transport_method=str(payload["transport_method"]),
|
|
cost=str(payload["cost"]),
|
|
rpe=_coerce_int(payload["rpe"]),
|
|
went_well=str(payload["went_well"]),
|
|
to_improve=str(payload["to_improve"]),
|
|
overall_feeling=str(payload["overall_feeling"]),
|
|
reservation_phone=str(payload.get("reservation_phone", "")),
|
|
techniques_practiced=str(payload.get("techniques_practiced", "")),
|
|
warm_up_minutes=str(payload.get("warm_up_minutes", "")),
|
|
pain_or_injury=str(payload.get("pain_or_injury", "none")),
|
|
matches_won=_coerce_int(payload.get("matches_won", 0)),
|
|
matches_lost=_coerce_int(payload.get("matches_lost", 0)),
|
|
sets_won=_coerce_int(payload.get("sets_won", 0)),
|
|
sets_lost=_coerce_int(payload.get("sets_lost", 0)),
|
|
racket=str(payload.get("racket", "")),
|
|
balls=str(payload.get("balls", "")),
|
|
activity_type_other=activity_type_other,
|
|
activity_details=str(payload.get("activity_details", "")),
|
|
equipment=str(payload.get("equipment", "")),
|
|
)
|
|
except (KeyError, TypeError, ValueError) as exc:
|
|
_logger.warning(
|
|
"Synced manual workout payload is malformed (%s: %s) — SKIPPING "
|
|
"this record, so it will not be logged or counted on the PC",
|
|
type(exc).__name__,
|
|
exc,
|
|
)
|
|
return None
|
|
|
|
|
|
def _draft_or_report(
|
|
record_id: str, payload: Mapping[str, object]
|
|
) -> ManualWorkoutDraft | None:
|
|
"""Rebuild the draft, or report why it cannot be rebuilt and return None.
|
|
|
|
The two failure modes are deliberately reported at different levels, because
|
|
only one of them is actionable:
|
|
|
|
* an **empty stub** carries no workout at all (seen as ``{type, kind,
|
|
date}``) -- nothing to recover, nothing to fix, so re-reporting it at
|
|
``warning`` on every 15-minute sync cycle only teaches the reader to
|
|
ignore this logger;
|
|
* a **malformed** payload has real workout fields that would not parse,
|
|
which means genuine credit may be going missing -- that stays loud.
|
|
"""
|
|
if is_empty_stub(payload):
|
|
_logger.info(
|
|
"Manual record %s is an empty stub (keys: %s) — no workout data to "
|
|
"ingest; skipping permanently, no credit is being lost",
|
|
record_id,
|
|
sorted(payload),
|
|
)
|
|
return None
|
|
draft = reconstruct_draft(payload)
|
|
if draft is None:
|
|
_logger.warning(
|
|
"Manual record %s is malformed — it HAS workout fields but they "
|
|
"could not be parsed, so real credit may be lost",
|
|
record_id,
|
|
)
|
|
return draft
|
|
|
|
|
|
def _already_ingested(logs: dict[str, list[dict]], record_id: str) -> bool:
|
|
"""True if any logged entry already carries this sync record id.
|
|
|
|
Iterates the per-day lists (a day may hold several workouts); a cheap
|
|
early-out before reconstructing the draft. The write chokepoint also dedups
|
|
by ``workout_id``, so this and that are two guards on the same idempotency.
|
|
"""
|
|
for entries in logs.values():
|
|
for entry in entries:
|
|
workout_data = entry.get("workout_data", {})
|
|
if (
|
|
isinstance(workout_data, dict)
|
|
and workout_data.get(_SYNC_ID_FIELD) == record_id
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def ingest_manual_records(
|
|
log_file: Path,
|
|
records: Iterable[tuple[str, Mapping[str, object]]],
|
|
*,
|
|
today: str | None = None,
|
|
on_ingested: OnIngestedCallback | None = None,
|
|
) -> list[str]:
|
|
"""Ingest synced manual workouts into ``log.json``.
|
|
|
|
For each ``(record_id, payload)`` tagged ``kind="manual_workout"``:
|
|
reconstruct + re-validate the draft on the PC, enforce the rate budget, then
|
|
HMAC-sign and APPEND it under its own ``date`` (a day may hold several
|
|
workouts). Idempotent: dedup by ``record_id`` here and by ``workout_id`` in
|
|
the write chokepoint, so re-syncing the same record is a no-op.
|
|
|
|
When ``on_ingested`` is given, it's called ``on_ingested(entry, prior_entries)``
|
|
right after each newly-appended record — ``prior_entries`` being that
|
|
entry's own date's entries written before it — so the caller (see
|
|
``screen_lock.ScreenLocker._ingest_synced_manual_workouts``) can apply the
|
|
identical live-workout shutdown/debt reward. This applies regardless of
|
|
whether ``date`` is today or back-dated: there's only one current shutdown
|
|
config, so a back-dated sync still pushes it exactly as a live workout
|
|
would have that day.
|
|
|
|
Returns the record ids actually ingested.
|
|
"""
|
|
ingested: list[str] = []
|
|
for record_id, payload in records:
|
|
if payload.get("kind") != MANUAL_WORKOUT_SYNC_KIND:
|
|
continue
|
|
date = payload.get("date")
|
|
if not isinstance(date, str):
|
|
_logger.warning("Manual record %s has no date — skipped", record_id)
|
|
continue
|
|
if _already_ingested(load_workout_log(log_file), record_id):
|
|
continue
|
|
draft = _draft_or_report(record_id, payload)
|
|
if draft is None:
|
|
continue
|
|
error = validate_manual_workout(draft)
|
|
if error is not None:
|
|
_logger.warning("Manual record %s invalid: %s", record_id, error)
|
|
continue
|
|
if is_budget_exhausted(log_file, today=today):
|
|
_logger.info("Manual-workout budget exhausted — %s not ingested", record_id)
|
|
continue
|
|
entry = build_entry(draft)
|
|
entry[_SYNC_ID_FIELD] = record_id
|
|
result = write_signed_entry(log_file, date, entry)
|
|
if not result.appended:
|
|
# This is dedup working, not credit being lost: the workout is
|
|
# already in the log under this same workout_id. It was logged at
|
|
# warning and re-reported on every 15-minute cycle, which read like
|
|
# a recurring fault and buried the lines that did need attention.
|
|
_logger.info(
|
|
"Manual record %s is already logged under the same workout_id "
|
|
"— skipped as a duplicate (its credit is already counted)",
|
|
record_id,
|
|
)
|
|
continue
|
|
ingested.append(record_id)
|
|
if on_ingested is not None:
|
|
on_ingested(entry, result.prior_entries)
|
|
return ingested
|