From 87e625cc5f9458cf24ac4059a7f59d80d098492d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 9 Aug 2026 17:34:46 +0200 Subject: [PATCH] Make the phone's manual-workout budget agree with the PC, twice over The phone and PC each compute a nominally SHARED manual-workout budget over the same merged records, and disagreed on both halves of it. The cap: _constants.py has said 10 per 30 days since 298daf0; the Dart mirror was still 5, so between 5 and 9 the phone hard-blocked the form for a workout the PC would have accepted and credited. The stricter side wins, so this cost logged workouts rather than granting extra ones. The counting rule: count_in_window counts ENTRIES ("each logged manual workout consumes its own budget slot"), while countManualBudget built a Set of date strings and counted DAYS -- despite a docstring claiming it mirrored the PC. Three workouts on one day read 3/2 on the PC, over its 7-day cap, but 1/2 on the phone, which kept accepting. Both now report 3 for that input. Per-entry is the correct semantic because each entry separately earns weekly-count and shutdown credit, so each must separately cost budget. The wire fixture could never have caught either: the caps and the counting rule never travel on the wire. The comment claiming it was "the tripwire that catches drift" was wrong and is corrected. The real guard, TestCrossLanguageBudgetConstants, parses manual_workout.dart and compares it to the imported Python constants -- one test that fails on EITHER side's drift. Asserting a Python constant against a Python literal would have stayed green through 298daf0, which is the drift that actually happened. Also: the history volume chart summed targetWeight x targetSets x targetReps, so a session where every set failed plotted identically to one completed in full -- the line only moved when progression changed the target. It now sums weight x doneReps over the logged sets. On today's real session that is 2790 -> 1440 kg, because 5 sets of bench press were logged at 0 reps and were being counted at full credit. No targetWeight fallback per set: weight is non-nullable and always serialized, and substituting the target would overstate a set the user never did. design.md/dfesign_v2.md: record that the Crucial (progress lost on exit) and High (sound + vibration) items are done, that breaks are still per-set, and that the warmup fraction shipped as 4/5 -- neither the 2/3 nor the 3/4 the specs asked for, and 4/5 is what is wanted. Verified on the phone (release build, signed with the shared key, installed with adb install -r so the workout history survived); 907 Python and 313 Dart tests pass with the coverage bar green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01A4y93ZSnk2w5LH3B12LHNc --- .../tests/test_manual_workout_features.py | 66 +++++++++++++++++++ stronglift_replacement/design.md | 1 + stronglift_replacement/dfesign_v2.md | 4 ++ .../lib/models/manual_workout.dart | 39 +++++++---- .../lib/screens/history_screen.dart | 34 ++++++++-- .../test/models/manual_workout_test.dart | 39 +++++++++-- 6 files changed, 158 insertions(+), 25 deletions(-) diff --git a/screen_locker/tests/test_manual_workout_features.py b/screen_locker/tests/test_manual_workout_features.py index 090d072..5f6ae55 100644 --- a/screen_locker/tests/test_manual_workout_features.py +++ b/screen_locker/tests/test_manual_workout_features.py @@ -3,14 +3,31 @@ from __future__ import annotations +import pathlib +import re from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch from screen_locker import _manual_workout, _sick_tracker +from screen_locker._constants import ( + MANUAL_WORKOUT_BUDGET_PER_7_DAYS, + MANUAL_WORKOUT_BUDGET_PER_30_DAYS, +) from screen_locker._log_mixin import RecordResult from screen_locker._sick_tracker import SickHistory from screen_locker.tests.conftest import create_locker +# The Dart mirror of the budget caps; read at test time so drift on +# EITHER side fails, not just a local edit to the Python constants. +DART_MANUAL_WORKOUT = ( + pathlib.Path(__file__).resolve().parents[2] + / "stronglift_replacement" + / "workout_app" + / "lib" + / "models" + / "manual_workout.dart" +) + if TYPE_CHECKING: from pathlib import Path @@ -307,3 +324,52 @@ class TestApplyWorkoutCredit: assert result.already_counted_today is False assert result.shutdown_adjusted is False locker.save_workout_log.assert_not_called() + + +class TestCrossLanguageBudgetConstants: + """Pins the budget caps against the Dart phone app's own source file. + + The wire-format fixture in test_manual_workout.py pins payload key NAMES, + but the caps never travel on the wire, so it cannot catch them drifting — + and they did: 298daf0 raised Python 5 -> 10 while touching zero Dart files, + leaving the phone at 5 for weeks. The stricter side silently blocks a + workout the other accepts. + + So this READS THE DART FILE rather than asserting a hardcoded literal on + each side. A test that compares a Python constant to a Python literal is + only a guard against a local edit; it stays green when the other language + drifts, which is the failure that actually happened. One test, either + direction. + """ + + @staticmethod + def _dart_const(name: str) -> int: + """Return the int assigned to `const int ` in the Dart model.""" + source = DART_MANUAL_WORKOUT.read_text(encoding="utf-8") + match = re.search(rf"const int {name} = (\d+);", source) + assert match is not None, ( + f"{name} not found in {DART_MANUAL_WORKOUT} — it was renamed or " + "removed; update this guard so the caps stay pinned." + ) + return int(match.group(1)) + + def test_dart_model_file_exists(self) -> None: + """A moved/renamed Dart model must fail loudly, not skip the guard.""" + assert DART_MANUAL_WORKOUT.is_file(), ( + f"{DART_MANUAL_WORKOUT} is missing; the budget caps are no longer " + "pinned across languages." + ) + + def test_per_7_days_matches_dart_mirror(self) -> None: + """The 7-day cap must equal kManualWorkoutBudgetPer7Days.""" + assert ( + self._dart_const("kManualWorkoutBudgetPer7Days") + == MANUAL_WORKOUT_BUDGET_PER_7_DAYS + ) + + def test_per_30_days_matches_dart_mirror(self) -> None: + """The 30-day cap must equal kManualWorkoutBudgetPer30Days.""" + assert ( + self._dart_const("kManualWorkoutBudgetPer30Days") + == MANUAL_WORKOUT_BUDGET_PER_30_DAYS + ) diff --git a/stronglift_replacement/design.md b/stronglift_replacement/design.md index f5f60ac..147d246 100644 --- a/stronglift_replacement/design.md +++ b/stronglift_replacement/design.md @@ -24,6 +24,7 @@ automatically decreases weight (in decrements of 2.5kg) if user failed to do the automatically decreases weight if user had a break from using the app Tracks how much time workout took -> Fully automatically, user CANNOT set it manually Adds optional warmup exercises (With weight equal 2/3 of target weight (rounding DOWN to nearest increment of 2.5kg) and always having 5 reps exactly and exactly one set) before each exercise (after warmup 3 minutes break too) + SUPERSEDED (2026-08-09): the warmup fraction shipped as 4/5, not 2/3 (nor the 3/4 in dfesign_v2.md). 4/5 is confirmed intended; see Exercise.warmupWeight in workout_app/lib/models/exercise.dart. adds breaks between exercises (3 minutes if exercise succeeded and 5 minutes if it failed) The user selects exercise as done by tapping on a circle with number of reps for this exercise, exercises are organized in rows where one row = one full set of one exercise, tapping on a circle again means that the user failed to do the exercise and it decreases the rep by "1" tappign again further reduces this count, if user holds finger over the specific circle they can reset the state of this circle (which should diff --git a/stronglift_replacement/dfesign_v2.md b/stronglift_replacement/dfesign_v2.md index e98feba..49e8b2b 100644 --- a/stronglift_replacement/dfesign_v2.md +++ b/stronglift_replacement/dfesign_v2.md @@ -3,13 +3,17 @@ This is a continouation from design.md file with what is left to be done and wha Crucial (max 1 feature): If user starts workout and later either exit the app completely or clicks the arrow in upper left the workout gets reset completely, all progress is lost this is very bad once user starts workout only by tapping finish and confirming that they INDEED finished workout should end it OR if user clicks and confirms RESET button, NOTHING ELSE + DONE (verified 2026-08-09): StorageService.saveActiveSession persists after every tap and resumes on the same set/rep; the back arrow is gone (automaticallyImplyLeading: false) and Finish/Reset both confirm. Mirrored to /sdcard so even an uninstall resumes. High (max 2 features): adds breaks between REPS (3 minutes if REP succeeded (as in all reps were done) and 5 minutes if it failed) <-- currently app ads breaks between SETS which wrong + STILL OPEN (2026-08-09): breaks are still per-SET, and suppressed on the last set of an exercise. Needs a decision — "break between reps" would mean pausing mid-set, which is unusual; confirm intent before building. After break time (after REPS) is over app should play a sound and vibrate the phone and generally point the user attention towards the app + DONE (verified 2026-08-09): break end plays assets/sounds/break_end.mp3 and vibrates 800ms. Caveat: only while the app is foregrounded — Dart timers suspend in the background, which needs a native foreground service (deferred). Mid (max 3 features): change warmup exercises weight from 2/3 to 3/4 of target weight + DONE, but shipped as 4/5 rather than 3/4 — 4/5 confirmed intended (2026-08-09). Warmups should be a selectable circles in a separate screen, optional but still interactive and after doing them give the user a 3 minute break ALSO The app should change between workout A and B AUTOMATICALLY, no user interaction if last workout done was "A" the next one should be "B" and then "A" and so on... diff --git a/stronglift_replacement/workout_app/lib/models/manual_workout.dart b/stronglift_replacement/workout_app/lib/models/manual_workout.dart index e3b0368..c845ec4 100644 --- a/stronglift_replacement/workout_app/lib/models/manual_workout.dart +++ b/stronglift_replacement/workout_app/lib/models/manual_workout.dart @@ -12,15 +12,20 @@ import 'package:crdt_sync/crdt_sync.dart'; // ── Shared constants ───────────────────────────────────────────────────────── // Hand-maintained mirror of the Python source of truth in -// `screen_locker/_constants.py` (the `MANUAL_WORKOUT_*` values); the two -// languages can't share a file, so the cross-language payload fixture (see the -// model test and its Python twin) is the tripwire that catches drift. +// `screen_locker/_constants.py` (the `MANUAL_WORKOUT_*` values). +// +// The payload fixture does NOT pin these: the caps never travel on the wire, +// so it cannot see them drift — and they did (298daf0 raised Python 5 -> 10 +// touching no Dart, leaving the phone at 5). The actual tripwire is +// `TestCrossLanguageBudgetConstants` in +// `screen_locker/tests/test_manual_workout_features.py`, which parses THIS +// file and compares it to the Python constants, so either side drifting fails. /// 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; +const int kManualWorkoutBudgetPer30Days = 10; /// Minimum session length, in minutes, for a manual workout to count. const int kManualWorkoutMinDurationMinutes = 20; @@ -353,18 +358,24 @@ class ManualBudget { /// Counts DAYS with a manual workout in the rolling 7-/30-day windows. /// -/// The budget is counted per DAY, not per workout: several self-reports on one -/// day consume a single slot (and only one counts toward the weekly total -/// anyway). Each payload's own `date` (YYYY-MM-DD) is the window key, so both -/// devices compute the same shared budget over the merged record set — this -/// mirrors the PC's `count_in_window` (screen_locker/_manual_workout.py). +/// The budget is counted per ENTRY, not per day: each self-report consumes its +/// own slot, so three workouts logged on one day cost three. That matches the +/// PC's `count_in_window` (screen_locker/_manual_workout.py), which is the +/// source of truth — each entry separately earns weekly-count and shutdown +/// credit, so each must separately cost budget. +/// +/// This counted DAYS until 2026-08-09 (a `Set` of date strings) while the PC +/// counted entries, so three same-day workouts read as 3/2 on the PC — over +/// its 7-day cap — but 1/2 here, and the phone kept accepting. Callers pass +/// one payload per synced record (`manual:T`), so same-day +/// workouts at different times arrive as distinct payloads. ManualBudget countManualBudget( Iterable> payloads, DateTime now, ) { final today = DateTime(now.year, now.month, now.day); - final weekDays = {}; - final monthDays = {}; + var week = 0; + var month = 0; for (final payload in payloads) { final dateStr = payload['date']; if (dateStr is! String) continue; @@ -374,8 +385,8 @@ ManualBudget countManualBudget( .difference(DateTime(date.year, date.month, date.day)) .inDays; if (days < 0) continue; - if (days < 7) weekDays.add(dateStr); - if (days < 30) monthDays.add(dateStr); + if (days < 7) week++; + if (days < 30) month++; } - return ManualBudget(week: weekDays.length, month: monthDays.length); + return ManualBudget(week: week, month: month); } diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen.dart b/stronglift_replacement/workout_app/lib/screens/history_screen.dart index eeef908..23be895 100644 --- a/stronglift_replacement/workout_app/lib/screens/history_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/history_screen.dart @@ -142,7 +142,18 @@ class _HistoryScreenState extends State { return result; } - /// (date, total volume) per session – sum of targetWeight × targetSets. + /// (date, total volume) per session – tonnage actually lifted, summed as + /// `weight × doneReps` over every logged set. + /// + /// This deliberately uses the per-set actuals rather than + /// `targetWeight × targetSets × targetReps`: with targets, a session where + /// every set failed plotted identically to one completed in full, so the + /// line only moved when progression changed the target. + /// + /// The target-based fallback is defensive only — `ExerciseResult.toJson` + /// has emitted `sets` since the app's first commit, so no row written by + /// this app lacks it. It exists for hand-edited or truncated backup JSON, + /// not for a migration that ever happened. List<(DateTime, double)> _totalVolumePoints() { final points = <(DateTime, double)>[]; for (final row in _rows.reversed) { @@ -150,10 +161,23 @@ class _HistoryScreenState extends State { double total = 0; for (final ex in (json['exercises'] as List? ?? const [])) { final m = ex as Map; - final w = (m['targetWeight'] as num?)?.toDouble() ?? 0; - final s = (m['targetSets'] as num?)?.toInt() ?? 0; - final r = (m['targetReps'] as num?)?.toInt() ?? 0; - total += w * s * r; + final sets = m['sets'] as List?; + if (sets == null || sets.isEmpty) { + final w = (m['targetWeight'] as num?)?.toDouble() ?? 0; + final s = (m['targetSets'] as num?)?.toInt() ?? 0; + final r = (m['targetReps'] as num?)?.toInt() ?? 0; + total += w * s * r; + continue; + } + for (final set in sets) { + final sm = set as Map; + // `weight` is a non-nullable double in SetResult and always + // serialized, so there is no targetWeight fallback here — an absent + // value would mean hand-edited JSON, and silently substituting the + // target would overstate a set the user never did. + final w = (sm['weight'] as num?)?.toDouble() ?? 0; + total += w * ((sm['doneReps'] as num?)?.toInt() ?? 0); + } } final date = DateTime.tryParse(row['date'] as String); if (date != null) points.add((date, total)); diff --git a/stronglift_replacement/workout_app/test/models/manual_workout_test.dart b/stronglift_replacement/workout_app/test/models/manual_workout_test.dart index 66130a7..4a31642 100644 --- a/stronglift_replacement/workout_app/test/models/manual_workout_test.dart +++ b/stronglift_replacement/workout_app/test/models/manual_workout_test.dart @@ -141,6 +141,19 @@ void main() { expect(record.id, 'manual:2026-07-13T18:00'); expect(record.fields['payload']!.$1, buildSyncPayload(wireDraft, _wireDate)); }); + + // NOTE: comparing a Dart constant to a Dart literal only catches a local + // edit — it stays green when Python drifts, which is the failure that + // actually happened (298daf0). The real cross-language guard is + // `TestCrossLanguageBudgetConstants` in + // `screen_locker/tests/test_manual_workout_features.py`: it parses this + // package's manual_workout.dart and compares it to the Python constants, + // so either side drifting fails. This test only pins the local values so + // a stray edit here is caught by `flutter test` too. + test('budget constants are the locally expected values', () { + expect(kManualWorkoutBudgetPer7Days, 2); + expect(kManualWorkoutBudgetPer30Days, 10); + }); }); group('validateManualWorkout', () { @@ -268,20 +281,34 @@ void main() { }); }); - group('countManualBudget (per-day)', () { + group('countManualBudget (per-entry)', () { Map payload(String date, String start) => { 'date': date, 'start_time': start, }; final now = DateTime(2026, 7, 16); - test('multiple workouts on one day count as a single day', () { + // Was "count as a single day" until 2026-08-09, which disagreed with the + // PC's count_in_window ("Counted per entry, not per day"): three same-day + // workouts read 3/2 there — over the 7-day cap — but 1/2 here, so the + // phone kept accepting what the PC had already refused. + test('multiple workouts on one day each consume a slot', () { final budget = countManualBudget([ payload('2026-07-13', '14:00'), payload('2026-07-13', '23:07'), ], now); - expect(budget.week, 1); - expect(budget.month, 1); + expect(budget.week, 2); + expect(budget.month, 2); + }); + + test('three on one day exhaust the 7-day cap, matching the PC', () { + final budget = countManualBudget([ + payload('2026-07-13', '09:00'), + payload('2026-07-13', '14:00'), + payload('2026-07-13', '19:00'), + ], now); + expect(budget.week, 3); + expect(budget.week >= kManualWorkoutBudgetPer7Days, isTrue); }); test('workouts on distinct days each count', () { @@ -292,13 +319,13 @@ void main() { expect(budget.week, 2); }); - test('same-day plus another day counts two days', () { + test('same-day pair plus another day counts three entries', () { final budget = countManualBudget([ payload('2026-07-13', '14:00'), payload('2026-07-13', '23:07'), payload('2026-07-15', '09:00'), ], now); - expect(budget.week, 2); + expect(budget.week, 3); }); test('ignores dates outside the 30-day window and future dates', () {