screen-locker/screen_locker/_manual_sync.py
Krzysztof kuhy Rudnicki 298daf028d Give manual workouts full effect parity; raise minimum to 5, double budget
Synced manual workouts never earned shutdown/debt credit (only weekly
count), which silently missed pushing shutdown time for a same-day
workout logged on the phone. Manual workouts now go through the same
credit logic as any other workout type, count individually toward the
weekly total and rate budget (no more once-per-day collapse), and stack
same-day shutdown bonuses like verified workouts. Also bumps the weekly
minimum from 4 to 5 and doubles the monthly manual-workout budget to 10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 13:50:39 +02:00

182 lines
7.6 KiB
Python

"""Ingest phone-/PC-synced manual workouts into ``workout_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
``workout_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"
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 _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 ``workout_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 = reconstruct_draft(payload)
if draft is None:
_logger.warning("Manual record %s is malformed — skipped", record_id)
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:
_logger.warning(
"Manual record %s collided with an existing workout_id — "
"skipped without credit",
record_id,
)
continue
ingested.append(record_id)
if on_ingested is not None:
on_ingested(entry, result.prior_entries)
return ingested