From f4d5337f25efdd10f0eb32388585a520ac2e2c84 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 13 Jul 2026 10:52:00 +0200 Subject: [PATCH] Manual-workout phone sync P1: wire format + shared constants + fixture First phase of syncing phone-logged manual/off-app workouts to the PC so they count toward the unlock. This phase only establishes the cross-language wire contract; no behavior change to existing flows. - _manual_workout.py: build_sync_payload(draft, date) = build_entry fields + a `kind="manual_workout"` discriminator + the workout's own `date`; and manual_sync_record_id(date, start) -> stable `manual:T` id. A StrongLifts session payload has no `kind`, so an ingesting PC can route on it and never mistake a manual for a verified session. - test_manual_workout.py: TestSyncWireFormat pins the exact literal payload as the cross-language contract (the Dart workout_app test will reproduce the same literal byte-for-byte; neither side's own round-trip test catches drift from the other). - workout_app/lib/models/manual_workout_constants.dart: hand-maintained mirror of the 7 MANUAL_WORKOUT_* constants + the kind value (Python stays source of truth; the shared fixture is the drift tripwire). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH --- screen_locker/_manual_workout.py | 36 ++++++++ screen_locker/tests/test_manual_workout.py | 82 +++++++++++++++++++ .../lib/models/manual_workout_constants.dart | 38 +++++++++ 3 files changed, 156 insertions(+) create mode 100644 stronglift_replacement/workout_app/lib/models/manual_workout_constants.dart diff --git a/screen_locker/_manual_workout.py b/screen_locker/_manual_workout.py index 20c1b3d..7a2a198 100644 --- a/screen_locker/_manual_workout.py +++ b/screen_locker/_manual_workout.py @@ -306,3 +306,39 @@ def build_entry(draft: ManualWorkoutDraft) -> dict[str, object]: } ) return entry + + +# ── Cross-device sync wire format ───────────────────────────────────────────── +# The value of the record ``kind`` discriminator equals ``MANUAL_WORKOUT_TYPE`` +# by design: on the wire a manual record is tagged ``kind="manual_workout"`` and +# its payload also carries ``type="manual_workout"``. A StrongLifts session +# payload has no ``kind`` field, so an ingesting PC routes on ``kind`` first and +# a manual record can never be mistaken for (or stamped as) a verified session. +MANUAL_WORKOUT_SYNC_KIND = MANUAL_WORKOUT_TYPE + + +def manual_sync_record_id(date: str, start_time: str) -> str: + """Return the stable crdt_sync record id for a manual workout. + + Prefixed ``manual:`` so it never collides with the bare-ISO session ids the + phone app writes into the same device log. Derived only from + ``(date, start_time)`` so re-syncing the same workout is idempotent — this + id must never be re-minted for a given workout. + """ + return f"manual:{date}T{start_time.strip()}" + + +def build_sync_payload(draft: ManualWorkoutDraft, date: str) -> dict[str, object]: + """Build the cross-device sync payload for a manual workout. + + This is the wire contract shared with the phone app: the same fields as + :func:`build_entry` (what lands in ``workout_log.json``), plus a ``kind`` + discriminator and the workout's own ``date``. ``workout_log.json`` is + day-keyed on the PC, but the synced payload must carry its date so the + shared budget can be windowed and the entry filed in the right ISO week on + ingest. + """ + payload = build_entry(draft) + payload["kind"] = MANUAL_WORKOUT_SYNC_KIND + payload["date"] = date + return payload diff --git a/screen_locker/tests/test_manual_workout.py b/screen_locker/tests/test_manual_workout.py index 3ec3963..b447aeb 100644 --- a/screen_locker/tests/test_manual_workout.py +++ b/screen_locker/tests/test_manual_workout.py @@ -22,8 +22,10 @@ from screen_locker._manual_workout import ( ManualWorkoutDraft, budget_summary, build_entry, + build_sync_payload, count_in_window, is_budget_exhausted, + manual_sync_record_id, validate_manual_workout, ) @@ -294,3 +296,83 @@ class TestBuildEntry: def test_pain_or_injury_defaults_to_none(self) -> None: entry = build_entry(_tt_draft()) assert entry["pain_or_injury"] == "none" + + +# The canonical manual-workout sync payload. This literal is the cross-language +# wire contract shared with the Flutter workout_app: an IDENTICAL literal lives +# in the Dart test suite (manual_workout model test). Neither side's own +# round-trip test can catch a key-name/format drift from the other (e.g. +# went_well vs wentWell, duration_minutes formatting) — only this shared literal +# on both sides does. If you change a field here, change it there too. +_WIRE_DATE = "2026-07-13" +_WIRE_DRAFT = ManualWorkoutDraft( + sport=SPORT_TABLE_TENNIS, + start_time="18:00", + end_time="19:30", + location_name="Solec", + transport_method="bike", + cost="40 PLN", + rpe=6, + went_well="Served consistently and moved feet well", + to_improve="Backhand topspin needs more consistency", + overall_feeling="Felt strong focused and in good rhythm", + reservation_phone="600100200", + techniques_practiced="topspin serve", + warm_up_minutes="10", + pain_or_injury="none", + matches_won=3, + matches_lost=1, + sets_won=7, + sets_lost=4, + racket="Butterfly", + balls="Nittaku 3-star", +) +_WIRE_PAYLOAD: dict[str, object] = { + "type": "manual_workout", + "source": "table tennis at Solec", + "sport": "table_tennis", + "activity_type": "table tennis", + "start_time": "18:00", + "end_time": "19:30", + "duration_minutes": "90.0", + "location_name": "Solec", + "transport_method": "bike", + "cost": "40 PLN", + "reservation_phone": "600100200", + "rpe": 6, + "techniques_practiced": "topspin serve", + "warm_up_minutes": "10", + "pain_or_injury": "none", + "went_well": "Served consistently and moved feet well", + "to_improve": "Backhand topspin needs more consistency", + "overall_feeling": "Felt strong focused and in good rhythm", + "matches_won": 3, + "matches_lost": 1, + "sets_won": 7, + "sets_lost": 4, + "racket": "Butterfly", + "balls": "Nittaku 3-star", + "kind": "manual_workout", + "date": "2026-07-13", +} + + +class TestSyncWireFormat: + """Cross-language wire-format contract for the manual-workout sync payload.""" + + def test_build_sync_payload_matches_fixture(self) -> None: + assert build_sync_payload(_WIRE_DRAFT, _WIRE_DATE) == _WIRE_PAYLOAD + + def test_sync_payload_is_json_serializable(self) -> None: + # The Dart side round-trips the same literal through JSON; guard that our + # payload contains only JSON-native types (no stray dataclass/objects). + restored = json.loads(json.dumps(build_sync_payload(_WIRE_DRAFT, _WIRE_DATE))) + assert restored == _WIRE_PAYLOAD + + def test_record_id_is_stable_and_prefixed(self) -> None: + assert manual_sync_record_id(_WIRE_DATE, "18:00") == "manual:2026-07-13T18:00" + + def test_kind_discriminator_present_and_typed(self) -> None: + payload = build_sync_payload(_WIRE_DRAFT, _WIRE_DATE) + # Absent-kind means a StrongLifts session; a manual must be tagged. + assert payload["kind"] == "manual_workout" diff --git a/stronglift_replacement/workout_app/lib/models/manual_workout_constants.dart b/stronglift_replacement/workout_app/lib/models/manual_workout_constants.dart new file mode 100644 index 0000000..7115bf4 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/models/manual_workout_constants.dart @@ -0,0 +1,38 @@ +/// Shared constants for manual (off-app) workout logging. +/// +/// These mirror the Python source of truth in the screen-locker package +/// (`screen_locker/_constants.py`, the `MANUAL_WORKOUT_*` values). The two +/// languages cannot share a constants file, so this is a hand-maintained +/// mirror; the cross-language sync-payload fixture (see the manual_workout +/// model test here and its Python twin, +/// `test_manual_workout.py::TestSyncWireFormat`) is the tripwire that catches +/// drift. Keep these values in sync with `_constants.py`. +library; + +/// Max manual workouts allowed in any rolling 7-day window. +const int kManualWorkoutBudgetPer7Days = 2; + +/// Max manual workouts allowed in any rolling 30-day window. +const int kManualWorkoutBudgetPer30Days = 5; + +/// Minimum session length, in minutes, for a manual workout to count. +const int kManualWorkoutMinDurationMinutes = 20; + +/// Minimum characters for the "other sport" activity description. +const int kManualWorkoutDescriptionMinChars = 40; + +/// Minimum characters required in each reflection field. +const int kManualWorkoutReflectionMinChars = 20; + +/// Lowest valid RPE (rate of perceived exertion). +const int kManualWorkoutRpeMin = 1; + +/// Highest valid RPE (rate of perceived exertion). +const int kManualWorkoutRpeMax = 10; + +/// Record `kind` discriminator marking a manual-workout sync record. +/// +/// A StrongLifts session payload has no `kind`; the ingesting PC routes on this +/// value before any session logic, so a manual can never be stamped as a +/// verified session. +const String kManualWorkoutSyncKind = 'manual_workout';