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:<date>T<HH:MM>` 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
This commit is contained in:
Krzysztof Rudnicki 2026-07-13 10:52:00 +02:00
parent 60d8c3361b
commit f4d5337f25
3 changed files with 156 additions and 0 deletions

View File

@ -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

View File

@ -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"

View File

@ -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';