Commit Graph

167 Commits

Author SHA1 Message Date
786d261d9e Split history_screen_test.dart (386) into three
This is the file the previous session's splitter could not handle: `_pump`,
`_wrap`, `_seed` (and `_syncMock`) were local functions declared inside
`main()`. A textual split copies the preamble into both halves, and the second
half's references then resolve against the wrong closure — which is why the
file was left on the list rather than split badly.

Hoisted all four to top level in _history_test_fixtures.dart, renamed to
pumpHistory / wrapHistory / seedSession / historySyncMock, then split by
subject:

  history_chart_test.dart        221  chart, drill-down, PC-synced rows
  history_screen_test.dart       117  app bar, list, calendar, tile states
  _history_test_fixtures.dart     99  the four hoisted helpers

None of the four closed over `main()` state, so hoisting is a pure move; the
runAsync/sqflite-ffi comment on `seedSession` travels with it, since that is
the non-obvious part a future reader needs.

402 tests pass. The name set gains only the new file's `(setUpAll)` /
`(tearDownAll)` reporter records.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 22:14:16 +02:00
a4b189ee3b Split progression_sync_service_test.dart (426) into three
Same two-step shape as the workout-sync split, done in one commit because the
fixtures here are smaller and have a single consumer each.

  _progression_test_fixtures.dart   101  FakeStore, ExplodingStore, seedRemote,
                                         hlcAt, markSynced
  progression_sync_push_test.dart   213  pushProgression, active session, result
  progression_sync_service_test.dart 155  paths, pullProgression

`_ExplodingStore` was the file's one post-`main()` declaration — the thing
that kept it outside the splitter's envelope — and is now a fixture.
`markSynced` was a local function inside `main()`, so it moved too rather than
being copied into both halves where the two could drift.

402 tests still pass. The test-name set gains exactly two entries,
`(setUpAll)` and `(tearDownAll)` for the new file; those are framework
lifecycle records in the JSON reporter, not tests, and the +402 count is
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 22:12:04 +02:00
1e72b61826 Split workout_sync_service_test.dart (740) into four
The largest file on the list. Now that the shared helpers live in
_sync_test_fixtures.dart, the two post-`main()` group functions were already
self-contained and became their own files rather than needing the splitter:

  workout_sync_service_test.dart          195  push / pushManual
  workout_sync_read_test.dart             238  readMerged*, token recovery
  workout_sync_manual_survival_test.dart  174  Firebase-connect survival
  workout_sync_cutover_test.dart           92  dual-write cutover

Each new file gets its own `TestWidgetsFlutterBinding.ensureInitialized()`.
That was previously supplied once by the shared `main()`, and
`installFakeSecureStorage` touches the test binary messenger — without it the
files would fail to load, which shows up as a dropped test count rather than
a red assertion.

402 tests pass and the test-name set is byte-identical to the baseline, so
nothing was lost, duplicated or silently skipped in the move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 22:09:54 +02:00
0c49039378 Move the workout-sync test helpers into _sync_test_fixtures.dart
Groundwork for splitting workout_sync_service_test.dart (740). The file's
shared helpers had to leave it first: `_cutoverTests` and
`_manualWorkoutSurvivalTests` are declared after `main()` and both reach back
into preamble helpers, so any split would have stranded one side of the file
from the fixtures it calls.

Moved and made public (a sibling import cannot see `_`-private names):
`_response`→syncResponse, `_fileContaining`→fileContaining, `_PutCall`→PutCall,
`_mockGitHub`→mockGitHub, `_session`→workoutSession, `_logPut`→logPut,
`_service`→syncService, `_fakeFirebase`→fakeFirebase.

`_manual` was a *local* function inside `main()` — the exact shape that broke
history_screen_test.dart last time, where both halves of a split get the
preamble copy but the second half's references resolve wrong. It is now the
top-level `manualRecord` in the fixtures file, so it survives the split.

Pure move: no test added, removed, renamed or reordered. 402 tests pass and
the test-name set diffs byte-identical against the pre-change baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 22:07:36 +02:00
78db299086 Split workout_screen.dart (633) into seven part files
The largest lib/ file on the 250-line list and the one never attempted. It
has no private widget classes to lift out (pattern A did not apply), so the
cut is by responsibility:

  workout_screen.dart        250  state fields, lifecycle, setState methods
  workout_screen_session.dart 182  save/restore, break maths, threshold write
  workout_screen_finish.dart  110  the write-everything finish step
  workout_screen_body.dart    105  exercise list + break banner
  workout_screen_dialogs.dart  73  finish/reset confirmations
  workout_screen_appbar.dart   68  clock + Reset/Finish actions
  workout_screen_breaks.dart   67  rest timers, threshold edits

Two constraints shaped it. `setState` is `@protected` and unreachable from an
extension, so methods that call it either stay in the class or split into a
class-side wrapper plus a setState-free body — that is why `_finishWorkout`
keeps only its three lines and delegates to `_persistFinishedWorkout`. The
breaks extension needs real state writes, so the class exposes one named
`_applyBreakState` shim rather than scattering the seam.

The extensions are named `_Foo`, not `Foo`: extending the private
`_WorkoutScreenState` from a public extension trips
`library_private_types_in_public_api`.

Coverage caught one real regression mid-way. Restructuring the threshold
update as a map-returning helper with an `if (s == null) return` guard added
a branch no test exercises and dropped the gate to 2753/2754. Rewritten as
`_writeThresholds`, mutating in place with the original `if (s != null)`
shape, so the extraction stays a pure move.

dart analyze clean; 402 tests pass with a byte-identical test-name set
(captured via --reporter json and diffed, since a count alone cannot tell a
lost test from a duplicated one); flutter coverage back to 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 22:04:11 +02:00
99302c2be7 Add todo_finish_dart_250_prompt.md and the Dart test splitter
A self-contained prompt for a fresh session to finish the last 10 files over
the 250-line cap, all Dart. It carries the parts that are expensive to
rediscover:

- scripts/split_dart_test.py, which works, plus the envelope it works in:
  one pass from the pristine file, no post-main() declarations, no local
  function declarations in the preamble. All four remaining test files sit
  outside that envelope, and the fix (hoist shared fakes into a
  _fixtures.dart sibling first, as its own commit) is written down.
- The three Dart constraints each of which cost a debugging cycle here:
  setState is @protected so it cannot be called from an extension; extension
  methods dispatch statically so a method a test fake overrides must stay in
  the class (this hung four tests on pumpAndSettle rather than failing an
  assertion); static members need qualifying inside an extension.
- 402 as the test-count invariant, and the note that dart analyze is a no-op
  on test/ so the count is the only signal there.
- That the local gatelock (0.5.0) is ahead of the pin (0.4.0), so the popup
  gate's failure here is environment drift and must NOT be "fixed" in code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:49:15 +02:00
96f6e5f8cb Refresh the refactor todo: 41 -> 10 files over the cap
Regenerated from the gate, so the resume state matches the tree: 6,753 lines
over the cap at the start of this pass, 1,828 left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:46:16 +02:00
2bb84f6d1b Split progression_sync_service.dart (433) into four
progression_sync_service.dart          client, record IO, JSON mapping
  progression_sync_service_push.dart     push local progression
  progression_sync_service_pull.dart     pull remote progression
  progression_sync_service_session.dart  the shared active session

Extensions are safe here: no test fake subclasses ProgressionSyncService, so
static dispatch cannot swallow an override the way it did for
WorkoutSyncService. Static members need qualifying from inside an extension
(ProgressionSyncService._stateToJson, .pathForExercise).

402 Dart tests; dart analyze clean; coverage gate passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:44:43 +02:00
9e4c45dfa3 Split manual_workout.dart (410) into four part files
manual_workout.dart             constants, the draft, buildEntry
  manual_workout_validation.dart  duration and per-sport rules
  manual_workout_sync.dart        sync payload + record id
  manual_workout_budget.dart      rolling budget accounting

This file is a cross-language contract: its JSON keys must match the Python
side's build_sync_payload / manual_sync_record_id. Nothing was retyped, and
both sides' fixtures still pass -- 16 Python wire-format tests and all 402
Dart tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:41:59 +02:00
5977475021 Split the private widgets out of three more Dart screens
exercise_tile.dart        319 -> 144  + exercise_tile_rows.dart
  github_mirror_screen.dart 441 -> 247  + github_mirror_screen_widgets.dart
  home_screen.dart          383 -> 260  + home_screen_cards.dart

Same `part` pattern as history_screen: the private StatelessWidgets move, the
State stays. home_screen lands 10 over -- its remaining methods all call
setState, which an extension cannot, and its build() is one cohesive tree.

402 Dart tests; dart analyze clean; coverage gate passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:39:57 +02:00
9184a6bc33 Split backup_service.dart; partly split workout_sync_service.dart
backup_service.dart (261 -> 175) + backup_service_session.dart
  workout_sync_service.dart (415 -> 322) + workout_sync_service_read.dart

An extension method is dispatched STATICALLY, so a method a test fake
overrides cannot move to one: `_FakeSync extends WorkoutSyncService` overrides
pushManual and readMergedManualPayloads, and with those in an extension the
real implementations ran instead of the fakes -- four manual-workout screen
tests hung on pumpAndSettle rather than failing an assertion. Both are back in
the class; only the private _readMergedPayloads/_fetchPayloads/_mergeRecords
helpers moved.

That leaves workout_sync_service.dart at 322: its public surface is what
callers and fakes bind to, so going lower means changing that surface, not
moving text. Static members also need qualifying inside an extension
(BackupService._baseDir).

402 Dart tests; dart analyze clean; flutter coverage gate passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:38:42 +02:00
3d992ef71b Split manual_workout_test.dart (350) into three by group
402 Dart tests before and after. The cross-language wire-format group moves
with its siblings and still pins the same literal keys the Python side emits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:32:58 +02:00
3a587d6cf9 Split github_mirror_screen_test.dart (470) into four
402 Dart tests before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:29:34 +02:00
8293dd88d0 Split settings_screen_test.dart (480) into four by testWidgets
This file is a flat list of testWidgets() calls with no group(), so the split
points are the test boundaries themselves. 402 Dart tests before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:28:40 +02:00
f74687064e Split storage_service_test.dart (759) into six by group
Every top-level group() lands in exactly one file, each reusing the original's
imports and setUp/setUpAll preamble. 402 Dart tests before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:28:02 +02:00
40a2f28734 Split DESIGN_AUDIT_TODO.md into one file per audited surface
341 lines -> a 19-line index plus three topic files under docs/design-audit/:
the Python/Tkinter lock UI, the Flutter workout app, and the shared gatelock
library. Findings are unchanged -- same 6 entries, same three section headers,
verified by counting both before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:27:19 +02:00
338ef5ed9b Split settings_screen.dart's widgets into part files (771 -> 503)
settings_screen_rows.dart        section header, reps row, weight row
  settings_screen_thresholds.dart  stepper button, threshold card and row

Still over the cap: the remaining 503 lines are the State class, whose ~190
line build() would have to be broken up to go further. Two routes were tried
and reverted rather than shipped half-working:

- an extension holding the async actions -- rejected by the analyzer, because
  setState is @protected and cannot be called from an extension. Worth
  recording: it rules out moving any State method that calls setState.
- an extension exposing the SYNC/OFFLINE BACKUP rows as a widget-list getter.
  That one is viable (those rows never call setState) but slicing a balanced
  chunk out of a nested list literal is not a mechanical move, and this screen
  ships to the phone.

402 Dart tests pass; coverage 100%; dart analyze clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:15:55 +02:00
d418994f6f Split storage_service.dart (781) into part extensions
storage_service.dart            the class: open, settings, active session
  storage_service_schema.dart     create / migrate / seed
  storage_service_exercises.dart  read+write a single exercise's row
  storage_service_sessions.dart   apply progression, record sessions
  storage_service_backup.dart     backup, restore, sync markers

Dart cannot continue a class body across files, so unlike history_screen these
are `extension`s in `part` files. Library privacy still applies inside a part,
so they reach `_db` and `_getSetting` unchanged and every caller still writes
`StorageService.instance.foo()` -- no public API change, nothing for
public_member_api_docs to demand.

Doing this exposed a bug in scripts/check_silent_failures_dart.py: its bare
`on Foo {` pattern (the catch clause form) also matched
`extension StorageServiceBackup on StorageService {`, so it reported the
declarations as swallowed exceptions. It now requires the try block's closing
`}` first. Verified both directions on a fixture: a real silent
`} on FormatException {` is still reported, the extension is not.

402 Dart tests still pass; flutter coverage still 100%; dart analyze clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:12:08 +02:00
38e8364306 Split history_screen.dart (897) into four part files
history_screen.dart          the State: load, pick, build
  history_screen_charts.dart   picker, stats card, streak row, chart widget
  history_screen_painter.dart  the CustomPainter
  history_screen_tiles.dart    the three session tiles
  history_screen_data.dart     pure transforms over the loaded rows

`part` files rather than separate libraries, for two reasons. Dart privacy is
library-scoped, so these `_`-prefixed widgets cannot be imported across a
library boundary -- making them public would be a real API change and
public_member_api_docs would then demand docs for every one. And a `part` does
not get its own SF: entry in lcov, so the coverage gate's "every lib file must
appear" guard has nothing new to miss.

The data helpers become top-level functions taking `rows` explicitly, since
they only ever read it -- which is also what let them leave the State class.

402 Dart tests still pass; flutter coverage still 100% (2717/2717);
dart analyze clean under very_good_analysis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:06:09 +02:00
b8cf634fa6 Record why the popup gate fails on a drifted gatelock
The RecoveryLoop call in HostileLock matches gatelock 0.4.0, which is what
requirements.txt pins and what the pylint hook installs. gatelock 0.5.0
regroups the five arguments into a RecoveryCollaborators object, so running
this gate against a locally-installed 0.5.0 fails with

    RecoveryLoop.__init__() takes 3 positional arguments but 6 were given

That is environment drift, not a defect in the check -- against the pinned
version the gate runs and passes both halves. Comment says so, and says how to
resync, so the next reader does not "fix" the code to match whatever happens to
be installed. (I nearly did; against the pin that edit would have broken CI.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 21:00:49 +02:00
76a4c6a0a7 Finish the Python side: scripts, conftest, and the last test modules
verify_lock_popup_safety.py (318) -> _xvfb_reexec.py      shared re-exec
                                       _popup_form_check.py HostileLock, ticks,
                                                            the real-form check
  verify_screen_fits.py (266)       -> uses _xvfb_reexec too
  conftest.py (329)                 -> _tk_mocks.py         the tk mock + the
                                                            module patch list
                                       _opt_in_fixtures.py  requestable fixtures
  three test modules                -> _part4/_part5/_part15 siblings

conftest keeps every autouse fixture. pytest applies autouse fixtures from
conftest only, so one moved to a sibling would silently stop running and take
its safety guard with it -- verified by asserting the fixtures are still
listed, not just that the suite is green. The five near-identical _isolate_*
fixtures collapse into one table-driven _isolate_state_files, which also picks
up the _shutdown_sick_state binding an earlier split created.

scripts/ becomes a real package so the shared helpers are importable. Both
gates now run as `python3 -m scripts.<name>`, including CI -- the path form put
scripts/ on sys.path instead of the repo root, and the alternative was a
try/except ImportError that this repo's no-silent-failures hook rightly
rejects. Each gate was run in its exact invocation form.

Note: verify_lock_popup_safety still fails on
`RecoveryLoop.__init__() takes 3 positional arguments but 6 were given`. That
is pre-existing -- confirmed by running it at HEAD in a clean worktree before
touching it -- a gatelock API drift, not this refactor. Left as found.

978 tests at 100% branch coverage. Gate: 41 -> 22 files, all remaining ones
Dart plus DESIGN_AUDIT_TODO.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 20:57:29 +02:00
8bc981da4e Split _runnerup_verification, _ui_widgets and _sick_dialog
_runnerup_verification.py (312) -> _runnerup_tcx.py       pull + parse TCX
                                     _runnerup_backfill.py  fill the log for a
                                                            date or a week
  _ui_widgets.py (287)            -> _ui_form_fields.py     labelled fields,
                                                            focus, disable_paste
  _sick_dialog.py (278)           -> _sick_commitment.py    next-day commitment

_ui_form_fields also takes disable_paste, since the fields are what use it;
_ui_widgets re-exports it for _manual_workout_widgets and _sick_dialog.

Two things had to move with the code rather than being left behind:

- conftest's _TK_MODULES gains _ui_form_fields, or its tkinter calls reach a
  real display instead of the mock.
- pyproject's S314 waiver follows the ET.parse call it covers into
  _runnerup_tcx.py. Same approved suppression, new home -- not a new one.

Test count 977 -> 978: _ui_form_fields is a new lock-surface module, so
test_no_popup_widgets safety-checks it too. 100% branch coverage held; pylint
back at 8.70 after fixing a genuinely undefined WidgetGroup annotation the
split exposed. `screen_lock --status` unchanged. Gate: 41 -> 28 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 20:37:15 +02:00
094a1fe5ed Split _workout_sync.py and _sick_tracker.py
_workout_sync.py (332) -> _sync_client.py   token, Firebase/GitHub client
                            _sync_records.py  payload predicates + log decoding
  _sick_tracker.py (317) -> _sick_budget.py   SickHistory + rolling budget

This one was not a pure move until the fixtures followed it. Three autouse
test guards patch names by module, and all three pointed at _workout_sync:

  isolate_sync_token      SYNC_TOKEN_FILE -> now also on _sync_client
  _no_real_firebase_config CONFIG_FILE    -> now on _sync_client
  _firebase_config helper  (7 call sites) -> now passed _sync_client

With them stale, sync_client built a REAL client: the suite went from 14s to
over 120s and test_workout_sync_part5 asserted against live workout data
pulled from the actual backend. Fixed, the same tests run in 0.7s. Both
modules keep SYNC_TOKEN_FILE so either patch point works, and every relocated
guard target is asserted to exist.

977 tests pass at 100% branch coverage; pylint back to 8.70 after fixing the
findings the split introduced (a dropped `return None` in _parse_iso and
leftover dead imports). `screen_lock --status` unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 20:30:42 +02:00
f5c6c2fb9d Split _manual_workout_dialog.py and _manual_workout.py
_manual_workout_dialog.py (357) -> _manual_workout_sport_fields.py
                                     ManualWorkoutSportFieldsMixin
                                     (+ _mw_scrollable_form moved into the
                                      existing widgets mixin, where the
                                      generic scaffold belongs)
  _manual_workout.py (355)        -> _manual_workout_types.py: the sport
                                     constants, ManualWorkoutDraft, and the
                                     rolling budget accounting

A painting mixin cannot simply move: conftest's mock_tk fixture patches `tk`
per-module through the _TK_MODULES list, so the new module had to be
registered there or its tkinter calls reach a real display. That is what the
two dialog tests caught.

Test count 976 -> 977: _manual_workout_sport_fields is a new lock-surface
module, so test_no_popup_widgets now safety-checks it too. Coverage stays
100%, pylint holds at 8.70/10, `screen_lock --status` unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 20:16:33 +02:00
bdc405c8c9 Split _shutdown, _phone_verification and _status_data
Six new modules, all composed or re-exported so no caller changes:

  _shutdown.py (384)          -> _shutdown_sick_state.py  SickDayStateMixin
                                 _wake_alarm.py           WakeAlarmMixin
  _phone_verification.py (380)-> _adb_transport.py        AdbTransportMixin
                                 _http_workout_fetch.py   HttpWorkoutFetchMixin
  _status_data.py (370)       -> _status_types.py         the 7 dataclasses
                                 _status_projection.py    shutdown projection
                                                          + format_summary_line

Moving a name means moving where it is *read*, so 30-odd mock patch targets
move with it. Most importantly conftest.py's autouse _block_real_network
fixture: ruff correctly dropped the now-unused `socket` import from
_phone_verification, which silently removed the guard's target and would have
let subnet probes reach the real network. It now blocks socket in both modules
that actually own the probes, and both targets are asserted to exist.

976 tests pass at 100% branch coverage; pylint 8.70 -> 8.71; `screen_lock
--status` renders the real week unchanged. File-length gate: 41 -> 35.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 20:11:28 +02:00
43a8b1d0f6 Split _compliance_state.py and status_view.py
_compliance_state.py was 399 lines and status_view.py 396.

_compliance_state.py becomes three acyclic modules:

  _compliance_predicates.py  the five leaf predicates + the gatelock
                             log_integrity imports
  _compliance_trace.py       PredicateResult / AutoUpgradeOpportunity /
                             LockExplanation, _stage, and
                             describe_auto_upgrade_opportunity
  _compliance_state.py       explain_lock_decision, importing both

Two-module shapes were all cyclic here: _compliance_state must export the
engine while owning the predicates it calls. Three modules break that with no
deferred import, no forwarder and no loss of the typed signature; every name
is re-exported, so _status_data.py and _log_mixin.py are untouched.

Because the hmac helpers now live in _compliance_predicates, 29 patch targets
across 5 test modules move with them -- patching a name where it is no longer
read would silently stop taking effect.

status_view.py's seven _section_* render methods become StatusSectionsMixin in
_status_sections.py, composed into StatusWindow alongside its existing mixins.

Test count goes 975 -> 976: test_no_popup_widgets parametrizes over
PACKAGE_DIR.glob("*.py"), so _status_sections.py is now covered by the
no-popup safety check too. Coverage stays 100%, pylint holds at 8.70/10, and
`screen_lock --status` renders the real week unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 20:01:12 +02:00
0714c2264d Split _ui_flows.py into sick-day and lockout flow mixins
_ui_flows.py was 370 lines. The sick-day flow and the lockout/phone-penalty
flow are both self-contained, so each moves to a sibling module:

  _ui_flows_sick.py     SickDayFlowMixin   (justification, countdown, debt)
  _ui_flows_lockout.py  LockoutFlowMixin   (lockout timer, no-phone penalty)

UIFlowsMixin composes both rather than exposing them as separate mixins, so
neither ScreenLocker nor scripts/verify_screen_fits.py changes its bases --
and ScreenLocker's ancestor count (already at pylint's max-parents limit)
stays where it was.

Pure move: 975 tests still pass at 100% branch coverage, pylint holds at
8.70/10, and `screen_lock --status` renders the real week unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 19:56:16 +02:00
3476dd3afd Split the last 8 test modules; scope pylint's test-inherent checks
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Splits test_screen_lock_coverage_part2 via a shared _unlock_helpers.setup_unlock
factory rather than duplicating the fixture, and finishes the remaining
runnerup/manual-workout/status-view test modules under the 250-line cap.

Also splits the pylint hook in two. protected-access, unused-argument and
duplicate-code are how pytest and unit-testing private mixins work, not
defects, and a commit made entirely of test files could never clear the 8.0
batch score with them counted. Source keeps the full rule set — verified that
an injected protected-access is still reported there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 19:40:32 +02:00
dd8296fd3f Split 12 more test modules under the 250-line cap
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
2026-08-15 19:34:59 +02:00
5c27769642 Split 10 more test modules under the 250-line cap
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:22:00 +02:00
4bdd180cb1 Add todo_fix_pylint_10_prompt.md: raise pylint to a 10.00 gate
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:18:39 +02:00
a0d78bc719 Split 13 test modules under the cap; document their test methods
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:16:18 +02:00
ee8f8bcf3a Add refresh_refactor_todo.py: self-correcting violations table
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:05:31 +02:00
4b4695e23a Split screen_lock.py under the 250-line cap
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:01:18 +02:00
c63a02f9eb Add sync-status card and duration-threshold fixture
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 18:41:31 +02:00
6abb553156 workout_app: drop the HTTP sync card from the home screen
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:25:42 +02:00
2e2a1fa12f Persist the CI-mirror's analyzer excludes and dependency upgrades
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The ci-mirror hook regenerates these on every run and then leaves them
dirty. Pre-commit stashes the dirty tree, the hook rewrites the same files,
and restoring the stash conflicts -- which rolled back the fixes and failed
every push regardless of whether the checks themselves passed.

Committing the hook's own output makes the tree clean, so there is nothing
to stash and the collision cannot recur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
2026-08-14 16:13:46 +02:00
8cbcabdcae Update refactor_claude_todo.md counts after gate exemption fixes
Git-ignored files, vendored agent-skill bundles and the CI checkout dir are
now exempt, since no commit could ever fix them. Repo counts and the ranked
violation lists are regenerated accordingly; the global baseline drops from
657 to 597.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
2026-08-14 16:03:01 +02:00
dc79579391 Add refactor_claude_todo.md: 250-line file-length cap
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
A ready-to-use prompt describing the goal (every file at most 250 lines,
code and prose alike), this repo's actual violations ranked by Fowler ROI
(lines x commits/yr), how to split each kind, and the requirement to wire in
the shared pre-commit gate so files stay small permanently.

Generated from a measured survey of every repo; the gate itself lives in
~/utils (file_length/ + scripts/check_file_length.sh).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
2026-08-14 14:25:41 +02:00
35c5f7adb1 workout_app: migrate Sync settings to the shared sync_settings_ui package
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Splits the settings screen: exercise thresholds/weights/reps/reset and the
offline-backup permission UI stay in place, Firebase+GitHub+Backup content
is replaced by a Sync settings ListTile using the shared sync_settings_ui
package. GitHub stays app-local as its own GitHubMirrorScreen (device-flow
connect, PAT field, verify-with-backup-recovery) since the shared package
carries no GitHub surface at all.

BackupSlot left unwired: there's no existing user-facing export/import
action for it to front (BackupService only runs from StorageService's
automatic write/startup paths), and inventing a manual import risked
clobbering current progression. Progression pull now fires unconditionally
after the Sync settings route pops (covers connect/disconnect/no-op alike,
since pullProgression already self-gates on remote-vs-local state).

365/365 tests passing, 100% line coverage, flutter analyze clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-14 11:21:16 +02:00
a2ce5049f0 Qualify a run on EITHER duration or distance, not both
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
MIN_RUN_DURATION_MINUTES and MIN_RUN_DISTANCE_KM were an AND (30+ min AND
5.0+ km), which rejected a fast 5 km run that took under 30 minutes. They
are now an OR at 40 min / 3.5 km: a run qualifies at either threshold
alone. Distance keeps its 5% GPS tolerance; duration, from the device
clock, does not.

Fixed one stale test whose fixture (1 min / 6 km) predated the OR change:
6 km alone now qualifies under the new rule, so the fixture needed to fail
BOTH branches to still exercise the "no file verified, return first
result" path it was written for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-14 11:04:18 +02:00
5d4d491fb7 workout_app: add Google sign-in support for the Firebase sync account
Same pattern as todo and diet_guard_app: openFirebaseWithGoogle()
(google_platform.dart abstracts the plugin's platform-channel
dependence), isFirebaseConfigured() reading the stored session rather
than the account marker alone, and the settings screen offering
"Sign in with Google" ahead of the password form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-14 10:56:16 +02:00
3a5f0962de Add a pre-commit gate against a local crdt_sync dependency_override
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
A dependency_override pointing crdt_sync at a local path is invaluable
while developing an unreleased library change, and silently catastrophic
if it survives the commit: local tests go green against code CI never
built. Enforce it can't ship instead of relying on someone remembering
to remove it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SH7XBkpEqQX4KghHdgx9T
2026-08-12 19:18:21 +02:00
507030d63b Retry RunnerUp verification every 15 min, and stop hiding why it fails
The auto-fill scan only ran once, at lock-screen login; a workout was
uncreditable for the rest of the day if the phone wasn't adb-visible at
that exact instant. Reuse the existing 15-minute workout-sync.timer to
retry it. Also upgrade the RunnerUp verification failure paths from
info to warning with the actual reason (no TCX export found, or root
DB access unavailable on this non-rooted device) instead of a vague or
invisible message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SH7XBkpEqQX4KghHdgx9T
2026-08-12 19:17:29 +02:00
aab51eed2d Carry progression through Firebase, and stop demanding storage permission
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
Progression and the in-progress session now live at
screen-locker-sync/exercise_state/<name> and .../active_session — siblings of
devices/, not inside it. Both workout readers (_workout_sync.py:113 and
workout_sync_service.dart:307) list only screen-locker-sync/devices, so a
progression record is structurally invisible to them and cannot be miscounted
as a workout, which would silently grant unlock credit. Keyed on exercise name
because a reinstall mints a new per-install uuid.

Startup no longer calls requestStoragePermission(): that threw the user into a
system Settings page on every launch, for a permission that is now genuinely
optional. It only checks; the grant moved to an OFFLINE BACKUP section in
Settings, its sole caller.

The clobber guard keys on a persisted progression_synced_at marker, NOT
looksFreshlyInstalled(). The only production caller (_finishWorkout) writes a
workout_history row and last_workout_type BEFORE it pushes, so a freshness test
was already false by then and could never fire — a reinstall that connected
Firebase late (the uninstall wipes the keystore, so startup's pull finds no
account) would have overwritten every real remote record with factory defaults
on its first finished workout. Connecting in Settings now pulls immediately,
which closes that window. The same marker stops a pull reverting a deliberate
setExerciseWeight/resetExerciseToDefaults, which write no history at all.

Also adds a Dart silent-failure pre-commit hook. The Python one is
types:[python] and had never looked at a single Dart file — in the app where a
swallowed sync error costs the user a workout with nothing anywhere saying so.
It found 21 real silent swallows (keystore reads/writes, /sdcard mirror
failures, the HTTP bind, device-flow polls, and a .catchError((_) {})), all now
logging why.

Verified on the Pixel 6a: reinstall + deny storage + connect late restored
Bench 22.5→20.0, Row 22.5→25.0, Situp 30→31 reps with the remote copy
untouched; the RTDB device list stayed at exactly 3 entries and no progression
leaked into pull_synced_workout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JupKvZCNKRmEeRQY3G1unG
2026-08-10 16:54:21 +02:00
e7fa7fb44c Let the PC see phone workouts again, and stop losing progression on reinstall
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
A real StrongLifts session on 2026-08-10 never reached this machine, so it
had to be logged as a manual workout -- spending a manual budget slot on a
workout that was genuinely verified. The session was in Firebase the whole
time; four separate defects each hid it.

- pull_synced_workout() read a hardcoded devices/phone/log.json. Since
  ffc6c72 the phone pushes under a per-install uuid, so that directory has
  been frozen since 2026-07-17 and the PC kept returning a stale session
  from 07-27 as if it were current. Both readers now walk EVERY device
  directory; reinstalls keep minting new ones, so no single path can be
  right for long.
- A session is identified by SHAPE (it has `exercises`), not by a `kind`
  field the phone never writes. That filter is why the one reader that did
  walk every directory discarded real sessions, and it also kept PC-origin
  runnerup/phone_verified records from being handed to a caller expecting
  a session.
- FirebaseSyncError is a SIBLING of GitHubSyncError, not a subclass, so
  `except GitHubSyncError` never caught it and a DNS failure took the whole
  locker down (status=1/FAILURE, then a restart loop). Catch the common
  parent; a pull failure degrades to a warning.
- A GitHub token is no longer required to sync. Firebase has been primary
  since eb4ff01, so a Firebase-only machine was reporting "sync is OFF"
  while syncing fine.

Sync also only ran once per locker start, so a workout finished after login
was invisible until the next login. workout-sync.timer runs the new headless
--sync-only every 15 minutes; remove_systemd.sh now removes every unit the
installer creates, not just two of them.

workout_app: the 2026-08-05 reinstall wiped progression because the DB seeds
factory defaults before the backup is restored, and the first write then
exported those defaults over the only off-device copy. Storage permission is
now settled before the DB opens, a defaults-only DB refuses to overwrite a
richer backup, and the three swallowed storage errors say what broke.

Sessions that exist only in Firebase/GitHub are restored into local history,
so a reinstalled app has the history it actually earned rather than starting
at whatever survived. Matched on (date, start_time), so re-syncing never
duplicates and local rows always win.

Adds a target-reps editor: progression can only ever RAISE reps, so a rep
target a defaults re-seed set wrong could not be corrected from the UI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JS5V44jkHZiH1wC7BH5h5n
2026-08-10 14:39:02 +02:00
b008992cb7 Show which sync backend the PC uses in the status window
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The status window is the PC's surface for workout data, but it said nothing
about the sync layer that data now arrives through -- so a machine that had
silently fallen back to the GitHub mirror, or was configured but had never
completed a push, looked identical to a healthy one. That is exactly how
todo's desktop app sat on GitHub unnoticed while every other surface moved to
Firebase.

Adds a "Sync backend" section and a --sync CLI flag (for i3blocks, alongside
--summary/--state), both rendering one line: which backend this machine is
configured for, when it last pushed, and how many peers it has merged.

Deliberately on-disk only -- no RTDB read, no Firebase sign-in -- because
gather_status() runs on every i3blocks tick and every window open; a network
round-trip there would be rate-limited nonsense. Everything shown is
knowable from the revision cache plus which credential files exist.

Coloured by `healthy` (configured AND has pushed) rather than by backend,
because "configured but never pushed" is the failure that looks fine in every
settings screen and produces no data.

Named _section_sync_backend, not _section_sync: another session is adding a
_section_sync that reports the outcome of a background sync *tick*. The two
are complementary -- did the tick run, versus what is this machine wired to
-- so they coexist rather than one overwriting the other.

Verified by running it, not just the suite: the real window shows
"firebase · pushed 2026-08-09 21:06 · 2 peer(s)" against this machine's live
state, and `--sync` prints the same line.

924 tests, 100% branch coverage. Every except logs at warning per the repo's
no-silent-failures hook; no suppressions added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 21:20:02 +02:00
4095e0ae5d Report a failed workout push instead of hiding it in debugPrint
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
A workout completed on 2026-08-09 never reached the sync repo and nothing
anywhere said why. The cause was structural: workout_screen.dart fired
`unawaited(WorkoutSyncService().push(session))` and `push()` caught ONLY
GitHubSyncError, reporting it via `debugPrint` -- which goes nowhere in a
release build. A Firebase error, an auth failure, a dropped connection or a
backgrounded app all produced exactly that symptom: silence.

This repo's CLAUDE.md already forbids it in as many words ("an invisible log
is a silent failure with extra steps", "return something meaningful"), so
this brings the Dart side up to the contract the Python side already meets
with `_manual_push.PushResult`.

- push()/pushManual() return a PushResult(pushed, reason) instead of void.
- The catch is deliberately broad, and logs at level 1000 (error) with the
  stack trace via dart:developer, not debugPrint.
- Still never rethrows: a sync failure must not cost the user their finished
  workout. The caller stays unawaited so a slow backend can't delay the
  summary dialog, but it now logs when the push did not happen.

Tests assert the reason, not just that the future completes -- the old test
("a GitHubSyncError is swallowed, not rethrown") would have passed against
the broken behaviour.

Note this does NOT explain the 2026-08-09 miss retroactively: that session
ran at 12:35 UTC on the pre-migration build, ~3h before the new APK was
installed, so its failure is unrecoverable. It is in Firebase anyway via the
PC's independent verification (phone_verified:2026-08-09, "68 min, partial").
This makes the next one diagnosable.

314 tests, analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 18:50:48 +02:00
2d331f1467 Derive versionCode from the commit count, not github.run_number
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
run_number counts workflow RUNS, not code. Two failure modes, one of which
already bit: it resets to 1 if this file is renamed (the existing NOTE warned
about that), and it can fall BEHIND a build produced outside this workflow.
On 2026-08-09 diet-guard's phone held a manually sideloaded vc 8 while CI's
next build was vc 6, so `adb install -r` refused every CI build with
INSTALL_FAILED_VERSION_DOWNGRADE. The only non-destructive escape was
re-running the workflow until the counter passed 8 -- forcing a downgrade
needs an uninstall, which wipes app data.

`git rev-list --count HEAD` only ever increases, tracks the code rather than
the CI file, and is already far ahead of every installed build (diet-guard
62 vs 9, screen-locker 118 vs 5, wake-alarm 41 vs 5, todo 51).

Needs fetch-depth: 0 -- checkout@v4 is shallow by default and would count 1.
Verified the count is identical from the repo root and from app/, so the
job's `working-directory: app` default does not affect it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 18:15:34 +02:00
87e625cc5f Make the phone's manual-workout budget agree with the PC, twice over
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4y93ZSnk2w5LH3B12LHNc
2026-08-09 17:34:46 +02:00