Commit Graph

122 Commits

Author SHA1 Message Date
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
ffc6c72df0 Push under a persisted per-install uuid, not a fixed role id
screen_locker pushed manual workouts as "pc" and the workout app as "phone".
A role constant collides the moment a second machine takes the same role, and
after a reinstall the new install inherits the previous one's CRDT identity.
Both halves now mint a uuid once and persist it: the PC in
~/.local/share/screen_locker/.device_id, the app under SharedPreferences
'crdt.nodeId' -- the same key every sibling app uses.

The old constant survives as the legacy id and is passed to sync_log /
syncLog, so devices/<legacy>/ is skipped as this device's own rather than
re-downloaded and re-merged as a peer's on every tick. It drops to None once
that path is reclaimed.

Note the *read* loop in _workout_sync deliberately still merges every device
including this one: the PC needs its own manual workouts in the merged view,
so "skip own" would be wrong there. Only the push path and the HLC node id
move.

The test fixture redirecting the new id file is added in the same change that
introduces the constant -- without it the first test that syncs mints a uuid
into the real state dir and this machine's live sync identity is decided by
whichever test ran first. Verified: ~/.local/share/screen_locker/ held no
.device_id after a full suite run.

Verified against the live backend, not just mocks: ran the real
push_pc_workouts, which minted 57582b3b and pushed 9 records. In the RTDB the
new device's log is a strict superset of both old paths -- 0 of devices/pc/'s
10 records and 0 of devices/phone/'s 9 are missing from it.

Python: 907 tests, 100% branch coverage. workout_app: 313 tests, analyzer
clean. Pins bumped to crdt-sync-v0.6.0 / crdt_sync_dart-v0.7.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
2026-08-09 17:31:15 +02:00
d31b488311 Add the Firebase connect UI, and stop dropping pushes from a Firebase-only phone
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
The last app on the list. The Firebase backend has been wired up since the
cutover commit, but nothing ever called saveAccount(), so openFirebase() read
an account that was never written, returned null every time, and the app
quietly synced over GitHub forever -- on real hardware, whatever the tests
said. Settings now has a Firebase section that stores the account and signs
in immediately, so a wrong password shows on screen instead of as a silent
no-op later; a rejected sign-in clears the account rather than leaving it
half-stored.

The worse half was in workout_sync_service. push(), pushManual() and the
read path each began `if (!settings.isConfigured) return;` -- true only when
a **GitHub token** exists. The MirrorStore that makes Firebase primary was
built *after* that check, so a phone connected to Firebase alone silently
dropped every workout it logged and reported no history, with no error
anywhere. Once the GitHub mirror is retired that would be every device. All
three now accept either backend.

UI follows the same shape as the other apps: GitHub demoted into "Advanced
(GitHub mirror)" (with the PAT fallback folded in, rather than two sibling
"Advanced" disclosures), and a connected account rendered as read-only text
with a Disconnect button instead of an empty-looking form.

The keystore accessors are injectable, like firebaseFactory already was, so
the connect/reject/disconnect branches are testable without a platform
channel. 302 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
2026-08-05 10:11:09 +02:00
eb4ff01e04 Sync workouts through Firebase, mirroring to GitHub during the cutover
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
Both surfaces of this repo move together, because they share one prefix:
screen_locker (the PC, which reads what the phone logged) and workout_app (the
phone, which writes).

Firebase becomes the primary backend when it is set up, with GitHub kept as a
mirror so a device that has not moved yet is still seen. MirrorSyncClient reads
the union of both, so a workout logged against either backend still counts --
which is what lets the PC and the phone cut over on different days.

The PC's two pulls are read-only, so they need only the client swapped.
_manual_push does a real sync_log and now carries a revision cache, as does
every push in workout_app: without it each push re-uploads the whole log and
each pull re-downloads every peer's, regardless of change.

remote_client is public rather than _private because _manual_push imports it;
one decision point beats two copies that can drift.

The config file is checked before constructing anything, so an unconfigured
machine never reaches the network -- without that guard the PC's sync tests
asserted against live Firebase data instead of their own fakes, which is how
this was caught.

screen_locker: 904 tests at 100% branch coverage.
workout_app: 299 tests at 100% line coverage; pinned v0.1.0 -> v0.6.0 across
three minor versions with no API drift. Four assertions moved off `.single`,
which now finds two writes: the log, then the revision published after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
2026-08-05 08:12:40 +02:00
e3cd68dc21 Build and publish a signed APK on every push to main
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
screen-locker had no APK build at all; releases were not produced. Adds a
standalone release-apk.yml plus the release signing config the app was
missing.

Signing follows the pattern proven in home_inventory: gradle reads
android/key.properties when present and falls back to the debug key when
absent, so local `flutter run --release` is unaffected. CI writes that
file from repository secrets and shreds it afterwards, and apksigner
fails the build closed if a debug-signed APK ever appears.

This repo has no Flutter analyze/test job, so the release job is not
gated on tests — a broken commit still publishes, with the build failing
as the only backstop. Add `needs:` if a Flutter CI job is added later.

versionCode is github.run_number, which is per-workflow-file — this file
must never be renamed, or the counter resets and updates stop installing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dzgcZxmpS3VtmLci2sbxj
2026-08-04 20:55:04 +02:00
7e04cf784b Move the crdt-sync pin to v0.3.0
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
Your in-progress bump, committed as its own change so it is not buried in
anything of mine. All three declaration sites move together -- the manifest,
the lockfile-equivalent and pylint's hook env -- because a pin that lags the
others lints the code against a library it does not actually run with.

Verified in a clean venv that v0.3.0 still provides every symbol this repo
imports: GitHubSyncClient, GitHubSyncError, Hlc, Record and Log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 19:54:40 +02:00
a0a3fb0eb0 Port the MCP server to mcp 2.x, and say what each tool is allowed to do
mcp 2.0.0 removed mcp.server.fastmcp; MCPServer is the same object under a
new name, so @mcp.tool() and mcp.run() are untouched and every tool keeps its
name, signature and returned shape. Verified against a clean 2.0.0 venv: all
four tools list, and the whole suite -- including test_mcp.py, which could not
even import while the dependency was broken -- passes at 100% coverage, 901
tests.

Two things the port buys, neither of which existed in 1.x:

* Tool annotations. The section banner here already said "state-only; never
  expose secrets, never mutate"; now the protocol says it too, as read-only,
  idempotent and closed-world hints, which is what lets a client run these
  without asking first. The day one of them starts writing, the annotation is
  what has to change with it.
* Structured output. 2.x derives an output schema from the return types and
  sends structured content alongside the text, so a caller no longer re-parses
  JSON we already had. No code change -- it falls out of the type hints that
  were already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 19:48:07 +02:00
feb7c173d5 Pin mcp below 2.0, and keep the crdt-sync bump out of my commit
CI has been red in three of these repos since 2026-07-30, for a reason that
has nothing to do with any commit made here: mcp 2.0.0 removed
mcp.server.fastmcp, which _mcp.py imports at module scope, and the manifests
asked for `mcp>=1.9` with no upper bound. Verified directly -- 2.0.0 raises
ModuleNotFoundError on that import, 1.29.0 provides it. Pinned `<2` in all
three places the dependency is declared (requirements, the optional extra,
and pylint's hook env) until the server is ported to the 2.x API.

Also backs the crdt-sync 0.1.0 -> 0.3.0 line out of pyproject.toml: that is
an in-progress change of yours that rode into dd233b2 when I staged the file
for its pylint init-hook. It is untouched in the working tree, where the
matching lines in requirements.txt and .pre-commit-config.yaml still carry
it, so the three move together whenever you commit them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 17:42:01 +02:00
dd233b27aa Run the fit check as a module, so it works where nothing is installed
CI caught what this machine could not: the hook died with "No module named
'screen_locker'". The package is pip-installed here (editable, pointing at
this checkout), so running the script by path found it anyway; a fresh
checkout has no install, and `python3 scripts/x.py` puts only `scripts/` on
sys.path. `python3 -m scripts.verify_screen_fits` puts the repo root there
instead, which is the checkout in CI and here alike -- verified in a
throwaway worktree, where the module now resolves to the worktree's own
package rather than the installed one.

The xvfb re-exec runs the same way, with an explicit cwd so the child does
not depend on where the parent was invoked from.

pylint had the same blind spot for the same reason: it infers sys.path from
the files it is handed, so this script resolved its imports only when a
package file happened to be in the same batch -- passing or failing by what
else was staged. An init-hook puts the repo root on the path unconditionally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 16:34:45 +02:00
a79c4fdd05 Stop the lock screen scrolling itself, and make every screen fit 768px
This morning the lock screen scrolled to the bottom and back with nobody
touching the keyboard, on the 1366x768 panel. Reproduced under Xvfb with the
real app: one programmatic focus_set() produced three scrolls,
frac 0.000 -> 0.436 -> 0.145. The cause is in gatelock (fixed in v0.4.0,
pinned here): the viewport followed every <FocusIn>, and this app focuses a
widget on every repaint. It now follows only focus the *user* moved, and a
screen that fits cannot scroll at all.

The other half is this app's own layout, because scrolling was covering for
screens that did not fit. The manual-workout form needed 1643px of a 768px
screen -- SUBMIT and BACK were simply off the bottom:

* Every font size and pad now goes through the design-system tokens rather
  than a literal. A positive size means *points* to Tk, so "36" rendered
  ~37% larger than the 36px intended; that alone was most of the overflow,
  on all 88 call sites.
* The form's cells put their label beside the field instead of above it, and
  the three reflection boxes share rows instead of taking one each.

Result, measured by the new scripts/verify_screen_fits.py (a pre-commit hook
now, so this cannot come back quietly): the form is 752px of 768px, and every
other screen -- including heat-skip, which had no viewport at all -- fits with
room to spare.

Also fixed, all found by measuring rather than by reading: content that fits
is centred again (the reset-to-top was cancelling it on every repaint), the
scrollbar only appears when there is something to scroll, and unlock_screen
moved to _unlock_view so screen_lock.py stays under the 400-line cap.

Tests: 896 pass. The focus/scroll helpers shipped in 3e77f4e had no coverage
at all; test_focus_and_scroll.py covers them now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
2026-08-03 15:57:48 +02:00
3e77f4e232 Make every lock screen keyboard-operable, and add the missing installer
The locker was not running at all. workout-locker.service crash-looped ~1977
times in a single boot with ModuleNotFoundError while remaining `enabled`, and
has no successful start in the retained journal. A locker that cannot start is
a locker that does not lock.

Root cause: commit 9b420a9 removed the unit's WorkingDirectory and
PYTHONPATH=/opt/screen-locker on the (correct in principle) grounds that
"screen_locker is now pip-installed" -- but nothing guaranteed that install.
There was no installer, /opt/screen-locker no longer exists, and the package
was absent from every Python version's user site-packages. The unit lost its
fallback and gained a hard dependency on a manual step nobody re-ran.

install.sh fixes that as a script rather than a reminder, and *gates*: it fails
if the production interpreter cannot import screen_locker, because an installer
that reports success while the service still cannot start is precisely the
failure above. It also re-asserts an editable gatelock from ~/utils AFTER the
package install and verifies it resolves there -- pyproject pins gatelock to a
git tag, so a plain install silently replaces a local editable gatelock with a
frozen wheel, which was observed removing ScrollableSurface and taking
diet_guard down with it.

The keyboard work, all of it Tk defaults rather than local mistakes:

- Focus was invisible. Tk ships highlightcolor="#000000" -- a black ring on
  #211D1B -- and relief="flat" removes the depth cue too, so a keyboard user
  had no indication of where they were. Now set in the _ui_widgets factory, so
  a new call site cannot omit it.
- Enter did nothing. X11 Tk binds <space> on Button and never <Return>.
- tk.Text trapped Tab (inserts a literal tab, refocuses itself) and no-opped
  Shift-Tab; the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab.
- The lock and retry screens started with NOTHING focused. on_focus_ready was
  a documented no-op arguing "buttons only, nothing to focus" -- exactly
  backwards: buttons are what needs focusing when there is no other window on
  screen and a global grab is held.
- The heat-skip modal grabbed input fullscreen with no focus, no ring, no
  Return and no Escape. It now focuses the *declining* option, so a blind
  Return keeps the workout rather than silently skipping it.

The manual-workout form's scroll viewport was pointer-only in three ways at
once: tk.Canvas has no class key bindings, so ::tk::FocusOK rejected it as a
focus stop; nothing bound wheel, PageUp/PageDown or arrows, leaving only a
draggable thumb; and canvas clipping does not unmap children, so below-the-fold
fields stayed in the tab ring while Tk never scrolled to follow focus -- Tab led
to fields the user could neither see nor reveal. Its height was also a magic
0.7*toplevel above ~230-290px of fixed chrome, i.e. satisfied at 1080p and
violated at 768p, where SUBMIT/BACK were pushed off the bottom.

All of that is gone by deletion: the surface container is now a
gatelock.ScrollableSurface, so the inner viewport is redundant. build_surface
and _ensure_container both use it, which fixes the same symmetric-clipping
problem for EVERY screen rather than one at a time; center_when_fits keeps
short screens looking exactly as they did.

Re-arming after a repaint hooks into clear_container(), which all ~19 repaint
sites already call. "Remember to call settle after painting" is a rule that
would eventually be missed, and on a lock a missed re-arm means unreachable
content.

The aux windows hardcoded geometry("600x400") and ("700x450") while displaying
more than that -- the relaxed-day prompt's own button row requests 862px inside
a 700px window, sheared at both edges. That pre-dates this work by a wide
margin (Tk's default focus ring accounts for 4px of the 162px overflow). They
now size to content, floored at the old values and clamped to the display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:25:45 +02:00
bc940f1112 Run the flutter hooks with the git hook's environment stripped
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
Same defect diet-guard's pre-push gate hit today, latent here: git exports
GIT_DIR to its hooks, and the flutter tool shells out to git to identify its
own SDK. With GIT_DIR set it reads THIS repository instead of /opt/flutter,
so `flutter --version` reports our own HEAD as the framework revision and our
URL as the repository, pub resolves the SDK as "0.0.0-unknown", and every
dependency constraint becomes unsatisfiable. flutter also caches that answer,
so the damage outlives the hook run and follows the next caller into another
repo.

Both flutter gates now clear the git variables at the point of use. Verified
by running the analyze hook with GIT_DIR set, which failed before and passes
now.

Also fixes the SC2295 shellcheck finding the coverage script had been carrying
-- it only surfaced now because no change had touched a shell file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:28:52 +02:00
c11e27f123 Pin gatelock 0.2.1, and stop the popup check passing when it cannot run
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
The lock-popup-safety job failed on its first CI run for two reasons, and only
one of them was the obvious one.

The obvious one: the script imports screen_locker directly rather than through
pytest, which would have put the rootdir on sys.path for it. CI now installs
the package.

The one worth having found: CI installs gatelock from the pinned tag, which
predated `holds_grab`, so every hostile tick died on an AttributeError --
inside a Tk `after` callback, where exceptions are swallowed and logged rather
than raised. The harness sailed past, printed OK and exited 0 while doing no
grab re-assertion whatsoever. A verification script that passes when it is
broken is worse than not having it.

So it now fails closed twice over: an explicit check that the installed
gatelock has the predicate at all, with a message naming the fix, and a count
of ticks that actually completed -- incremented after the work, so a raising
tick does not count. Fewer completed ticks than asked for is reported as "the
harness itself is broken, so this result proves nothing", not as a pass.

The pin moves to gatelock-v0.2.1, which carries both of yesterday's fixes: the
grab predicate that no longer steals the grab off our own posted menus, and
the recovery loop that no longer stops forever when a tick raises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:18:04 +02:00
c0aae7d05b Make the lock overlay's sport selector usable, and focus its form fields
The manual-workout sport selector was a `tk.OptionMenu`, and while the screen
was locked it could not be changed. A posted Tk menu is a separate
override-redirect toplevel that takes the Tk grab for itself, and gatelock's
1s recovery tick attacks it from both sides: `_surfaces.enforce()` lifts the
lock surface over the menu, and `_reassert_grab()` read the grab sitting on
the menu as "the grab was lost" and yanked it back. The menu opened on click
and died within a second. On 2026-07-26 that forced an hour-long walk to be
logged through the table-tennis form, with the reason typed into `racket`.

Radio buttons instead. They are ordinary children of the surface, so they
lift with it and live inside the root's grab tree -- neither mechanism can
reach them. That is a structural guarantee, not a tuned workaround; the
alternative, teaching gatelock to skip its lift while a popup is posted,
would open a window where something could cover the lock. They are also the
first version of this control styled from the shared palette, where the
OptionMenu was default grey on the dark overlay.

Nothing on a lock surface may open a popup, so two halves enforce it. The
static half walks each module's AST for popup-creating names -- AST rather
than a source regex because the modules explain this bug in prose that names
the very widgets being banned. Its module list is derived ("imports tkinter",
minus a short reasoned exemption list) rather than hardcoded, so a new lock
screen is in scope the day it is written; a hardcoded list drafted today
would already have missed `_sick_dialog`. The behavioural half is
`scripts/verify_lock_popup_safety.py`: it builds the real form with real Tk,
replays both hostile behaviours for five ticks, and re-runs the original
failure so the mechanism stays demonstrated rather than described. It
re-execs itself under Xvfb -- a global grab on the developer's own session
would freeze it -- and a CI job runs it, because the pytest suite replaces
the whole `tk` module and so cannot host it.

Separately, no field ever took keyboard focus. Under a global grab that means
typing goes nowhere with no other window on screen to reveal why. The
manual-workout and sick-justification forms now each name their own first
field via a `focus=` flag; `on_focus_ready` genuinely has nothing to focus
(it fires before those screens exist) and its docstring now says that instead
of claiming the lock window has no typed input at all.

`scripts/fix_2026_07_26_sport.py` re-files that walk as sport "other",
rebuilt through the form's own draft/validate/build path so it carries
exactly the writer's key set, and re-signed. `start_time` and the date are
untouched so `workout_id` stays stable and the phone converges instead of
gaining a duplicate. Kept as the audit trail for the edit; the walk's details
come from the entry and `--details`, so nothing personal lands in a tracked
file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:08:08 +02:00
f617be72b4 Type the surface groups properly and exempt the test helpers by name
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
Two things the CI mirror caught that the local pre-commit run did not.

The groups were annotated with `Any`, which ANN401 rejects. They are generic
over the widget they mirror now (`WidgetGroup[tk.Frame]`, `[tk.Text]`, ...),
so each subclass keeps its own methods typed instead of erasing them --
better than the `Any` it replaces, not just quieter.

`name-tests-test` requires every tracked file under tests/ to be named
test_*. The two helper modules the conftest split produced are not tests, so
they join the exemption `_status_view_helpers.py` already had; the pattern is
generalised to private (leading-underscore) helper modules rather than
listing files one at a time.
2026-07-25 16:56:59 +02:00
600d0fd084 Migrate screen_locker to gatelock v0.2.0 per-output locking
The lock now paints one container per live output instead of one window on
the primary, and claims the screen through an Arbiter at RANK_SCREEN_LOCKER
so the wake alarm can outrank it.

The fan-out happens at the widget factory, not the render loop. Painting a
screen here is not a pure function -- the ~20 clear_container() flows record
workouts, credit weeks and branch on state while they paint -- so re-running
a flow per surface would double those side effects. Instead `_label`,
`_button_row` and friends build N copies and return a group that mirrors
later configure calls to all of them, and a flow body still runs once. Frame
groups nest, so `_button(button_row, ...)` reads exactly as it did.

The degenerate case carries as much weight as the general one: the status
window, the post-sick-day verify window and the relaxed-day prompt have no
lock and no surfaces, and share this same factory. They get a group of
exactly one frame and behave as they always did.

Text boxes are the one thing that cannot be shared. Entries fan out over a
single StringVar, but tk.Text has no such variable, so the sick-day
description genuinely diverges per monitor; TextGroup.get() returns whichever
copy was actually typed into, preserving "answer on any monitor".

Tests: the suite hung rather than failed on first run, which is the trap
gatelock's own conftest documents -- screen_locker patches its own `tk`, but
LockWindow builds Toplevels through gatelock's, and real tkinter over a
MagicMock root recurses without bound. `_gatelock_fixtures` closes that and
redirects the runtime dir so no test can stand a production locker down.
Every other test runs single-output, where fan-out is unobservable, so
test_surface_fanout runs on two outputs; each assertion was checked by
cropping the group loops to the primary and confirming it goes red.

874 tests, 100% branch coverage, pre-commit clean, no lint suppressions.
2026-07-25 16:26:39 +02:00
323e3cd02a workout_app: warm-tint the unified-design-system palette
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
Replaces the dead ColorScheme.fromSeed(Colors.indigo) (declared but
never consumed by any screen/widget) with theme.dart: an explicit
dark ColorScheme plus a real AppStatusColors extension, since this
app genuinely uses success/warning status colors throughout (unlike
dufs-cloud's app). Routes all 10 screen/widget files' ~280 hardcoded
Colors.*/fontSize literals through it. Fixes the two flagged
DESIGN_AUDIT_TODO violations: home_screen's Resume/Start buttons now
use a real 2x horizontal:vertical padding ratio, and rep_circle's
BoxShadow (the one shadow in an otherwise flat dark UI) is removed.
2026-07-22 21:08:45 +02:00
0c6f6957a8 Wire gatelock.LockConfig into every window, not just the lock overlay
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
self._colors = LockConfig(...) is now built unconditionally in
ScreenLocker.__init__ (previously only the fullscreen lock path built a
local `config`), and every raw hex literal across status_view.py,
_ui_flows.py, _ui_flows_relaxed.py, _sick_dialog.py,
_manual_workout_dialog.py, _manual_workout_widgets.py, and
_temperature_status_mixin.py now reads from it instead -- replacing
ad hoc greens/oranges/reds (#00ff00, #0066cc, #ff4444, ...) with the
shared success/accent/danger/warning/muted tokens. Also fixes the
verify-window and status-window paths that still hardcoded #1a1a1a for
the background.

This was the prerequisite the on-fill fix (previous commit) actually
needed: _window_setup.py/_ui_widgets.py/_heat_skip.py reference
self._colors, which nothing in the codebase constructed outside the
fullscreen lock path until this commit.

Verified: 859/859 tests, 100% coverage.
2026-07-21 21:45:59 +02:00
96701134c6 Fix stale adaptive_icon_background left over from the warm-palette icon regen
pubspec.yaml's adaptive_icon_background is a separate literal from
python_pkg/app_icons' own style.BACKGROUND -- the icon regen updated
every generated PNG but missed this one, so the actual on-device home
screen icon (Android's adaptive icon, not the legacy mipmap) stayed on
the old cool charcoal. Caught on the physical device, not by file checks.

testsAndMisc's generator now syncs this automatically going forward.
2026-07-21 21:42:59 +02:00
6629260256 Fix on-fill text contrast on filled buttons, bump gatelock pin
_ui_widgets.py's _button() always set fg=self._colors.fg (near-white)
regardless of bg, so any accent/success/warning/danger-filled button
under-contrasted (measured 2.2-3.6:1 vs the 4.5:1 floor). Added
_fg_for(bg) so fg is derived from bg instead of hardcoded -- fixes every
current _button() call site structurally, not per-instance. Found and
fixed 2 more standalone instances of the same bug outside the shared
helper (_window_setup.py's demo-close button, _heat_skip.py's heat-skip
button).

Bumps gatelock to the released v0.1.1 carrying the same on_fill token
plus the warm palette rework. Regenerated workout_app's launcher icon
assets to match.

Verified: gatelock 50/50 + screen_locker 859/859 tests, both 100%
coverage; pre-commit (incl. pylint against the real v0.1.1 install) green.
2026-07-21 21:17:14 +02:00
541db3701d Add a design-rules audit against the new safe-design-rules checklist
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
Report-only findings for a future cleanup pass; no code changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 15:25:20 +02:00
7eca3f4145 Pin the StrongLifts week-scan tests to a fixed Wednesday
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
test_fills_gap_for_past_week_day and test_rescanning_an_already_filled_day
built their fixture date as date.today() - 1 day and asserted it was "this
week". _try_fill_stronglifts_for_week accepts week_start <= entry <= today,
and on a Monday week_start IS today — so yesterday (Sunday) belongs to the
previous ISO week and both tests failed. They passed Tue-Sun and failed every
Monday, which is exactly when they were noticed: they blocked an unrelated
push.

Pin "now" to Wednesday 2026-07-22 with the _FakeDatetime pattern already used
in test_status_part2, so the fixture day is Tuesday — comfortably inside the
week whatever day the suite actually runs. Verified under faketime on Mon,
Tue, Fri and Sun: 8 passed each time.

The neighbouring "last week" case needs no pinning: 8 days back always
precedes a week start, on any weekday.

Also covers the remaining branch in _try_fill_stronglifts_for_week — an
in-week JSON that fails content validation must not be written. It was
unreachable on a Monday, so the file had never hit 100% on one; the suite is
now 859 passed / 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 08:57:25 +02:00
b0d9165a79 Retry sync past the boot/resume network race, and stop blaming the token
The morning routine is started by systemd right after boot and right after
resume, so its sync ran seconds before DHCP/DNS were up. Every such run failed
outright — and said this:

    Workout sync push FAILED for 6 workout(s): network error reading
    screen-locker-sync/devices — a 403 here means the token lacks
    contents:write on kuhyx/syncs

Both halves of that were wrong. It was not a 403, and the token was fine: the
same six workouts pushed successfully by hand minutes later on the same token,
which reports push=true on the repo. The message cost a real investigation
chasing a permissions bug that did not exist.

- _sync_retry.with_sync_retry wraps the three sync call sites in a bounded
  retry (4 attempts, 2s/4s/8s backoff — ~14s, enough for the network to come
  up, short enough not to drag the routine). Verified offline in a network
  namespace: three retries, then an honest give-up.
- Any GitHubSyncError retries, deliberately including RepoNotFoundError.
  Classifying transient vs permanent would couple us to which exception
  crdt_sync chains where; a few wasted seconds on a genuinely bad token is the
  cheaper mistake.
- The push failure log now reports what actually happened. The contents:write
  hint survives only for RepoNotFoundError, which is the one error that
  actually implicates the repo or the token.

Retry at the application layer rather than ordering the unit after
network-online.target: the same race hits on resume, where boot ordering does
not apply, and these are user units that cannot depend on a system target.

The tests' autouse no-sleep fixture patches the module-local `sleep` name
rather than `_sync_retry.time.sleep` — the latter mutates the shared time
module object and silently disabled sleep for the whole suite, breaking an
unrelated temperature timeout test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 08:46:37 +02:00
135545ad1e workout_app: add trailing newline to generated colors.xml
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
flutter_launcher_icons writes the file without one, which trips the
end-of-file-fixer hook on push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:04:35 +02:00
a3997321ba workout_app: replace the stock Flutter launcher icon
Adopt the shared icon family (python_pkg/app_icons in testsAndMisc): a
charcoal #1B1D21 field carrying a crimson barbell glyph. Crimson rather than
this app's indigo seed, because dufs_client already owns indigo and the two
sit side by side in the launcher; crimson is the app's own accent colour
(Color(0xFFFF4444)), so the icon still matches the UI.

Adds the adaptive-icon layers the project never had, including an Android 13
monochrome layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:58:53 +02:00
9172b1ec8a Fix CI-only failure: avoid dating a test entry "today" without HMAC
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
has_logged_today treats an unsigned today-dated entry as "logged" only
when no HMAC key is configured on the machine (CI has none; this dev
box has a real key), so the test's precondition diverged between
environments. Reach the new weekly minimum via Jan 1-4 instead of
adding a same-day entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 13:56:44 +02:00
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
57c3f30e7b Add change explanation: check-phone-week-aware
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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERWuV1nExiMfiNb9Gb7AQt
2026-07-18 07:08:11 +02:00
c277325a3e Make Check Phone week-aware; add RunnerUp GPS tolerance; fix CLI weekly count
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
Check Phone only ever checked today's date, so a verified workout from
earlier in the week (e.g. yesterday's run, found once the phone connects
later) was never backfilled unless you ran the --status CLI. It now falls
back to the existing RunnerUp week-scanner plus a new StrongLifts
equivalent, still pulling and validating the real file via ADB before
crediting anything.

GPS distance tracking undershoots on tight turns/tree cover, so a run
within 5% of the 5.0 km minimum (e.g. 4.9 km) is now accepted instead of
silently rejected — duration is clock-based and gets no such leeway.

Also fixes a pre-existing bug in the --status CLI surfaced by this change:
its weekly-total summary derived counts from a per-day checkmark (max 1
per day) instead of screen_locker._weekly_check.count_weekly_workouts,
undercounting any day with more than one verified workout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERWuV1nExiMfiNb9Gb7AQt
2026-07-18 06:55:55 +02:00
8cb1ce0be8 Add change explanation: multi-workout-day-and-sync
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
Walkthrough of e92a74c..65380df: why the day-keyed log had one slot per
day, the anti-gaming counting rule (verified stack, manual caps at 1/day),
the workout_id dedup and the legacy-entry bug it missed, deriving the sync
push from workout_log.json, and the no-silent-failures hook.

Includes a micro-world for the counting rule: add verified/manual workouts
to days and watch the weekly count and the lock decision, including the
4-manual cheat the cap exists to stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
2026-07-17 11:05:41 +02:00
65380df425 Mirror the in-progress session so an app-data wipe can't lose your set
The active session (which set you are on, and the reps done so far) lived
only in app-private SQLite. An uninstall — or `pm clear`, which sounds
harmless and is not — wipes that, so a user standing mid-workout loses the
exact set and rep they were on. `backup.json` did not help: it mirrors only
COMPLETED history. This is not hypothetical; it happened, mid-workout, and
the state had to be re-entered by hand.

Persistence granularity was already right (saveActiveSession fires on every
set tap, untap, warmup and break change, storing `tapped` + `doneReps`).
Only the LOCATION was wrong, so this mirrors it the same way the sync token
already is:

- BackupService.exportActiveSession/readActiveSession keep
  /sdcard/WorkoutTracker/active_session.json; a null export clears it.
- StorageService.saveActiveSession mirrors on every write;
  loadActiveSession falls back to the mirror and re-seeds the table when it
  is empty — exactly the state after a wipe; clearActiveSession clears the
  mirror too, so a finished workout is never resurrected.
- Both reads are defensive: a truncated/non-object mirror (battery death
  mid-set) is ignored rather than bricking the resume.

Tested by simulating the wipe in the suite (resetForTesting + fresh init
with the mirror intact), not on a real device holding real data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
2026-07-17 09:57:54 +02:00
b026dfd074 Recover the sync token from backup when the stored one is rejected
SyncSettings.load() prefers the keystore and only consults the external
backup when the keystore is EMPTY. So a stale keystore entry — one left by
an earlier authorization — shadows a perfectly good backup indefinitely:
every sync 401s and the user is told to re-authorize for nothing. This is
not hypothetical; it is the most likely explanation for the phone's 401,
whose token turned out to be healthy (GET /user 200, scope repo) once read
from the backup.

- SyncSettings.recoverFromBackup(rejectedToken) re-seeds the keystore from
  the backup and returns the recovered token, or null when there is nothing
  to recover (no backup, empty, or the same token that was just rejected).
- settings_screen retries verification once via the backup before demanding
  re-auth, and says so ("recovered the saved token from backup") instead of
  nagging.
- workout_sync_service retries the read once from the backup, so history
  self-heals without the user having to visit Settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
2026-07-17 09:45:12 +02:00
79879263ba Support multiple workouts per day; fix the dead sync; ban silent failures
The workout log stored ONE workout per calendar day, so a second same-day
workout was silently dropped and could never earn credit.

Multiple workouts per day:
- workout_log.json: {date: entry} -> {date: [entry, ...]}. Legacy entries are
  wrapped on read (_log_io.load_workout_log) so their HMACs stay valid — no
  re-signing; every write normalizes the file, migrating it progressively.
- New write chokepoint (_log_mixin.write_signed_entry) APPENDS and dedups by a
  stable workout_id, returning RecordResult so callers can tell a new workout
  from a duplicate. Re-verifying the same workout is a no-op.
- count_weekly_workouts is anti-gaming: each VERIFIED workout counts
  individually; self-reported manual entries count at most once per day. Four
  real workouts in one day satisfy the week; four manual entries count as one.
- Reward: the day's first counted workout gets the base +2h; an additional
  same-day VERIFIED workout gets +1h; an additional manual gets nothing. The
  old "+1h when weekly_count > 4" gate is replaced — weekly volume is the
  banked bonus's job and shouldn't be paid twice.
- Fixed: the unlock screen labelled the +1h "#{weekly_count - 4}", which can
  render negative under the new rule. Removed the dead runnerup_manual type.
- Flutter: the manual budget counts distinct DAYS, matching the PC.

PC->phone sync (it had never worked once):
- push_pc_manuals pushed from a side-store written at log-time, so anything
  logged before that feature existed could never publish. _manual_push now
  derives the crdt log directly from workout_log.json — the single source of
  truth — covering manual AND verified runs. The record id is the workout's own
  workout_id (derived for legacy entries to match the phone's format) and the
  HLC comes from the entry's own timestamp, so a re-push is byte-identical and
  the repo never churns.
- The phone reads every kind (readMergedWorkoutPayloads) and lists the PC's
  workouts under "SYNCED FROM PC". The manual budget still sees manual-only, so
  a verified run can't consume a self-report slot.
- Settings claimed "Connected." whenever a token STRING existed. It now
  verifies against the API on open, so a revoked token reports 401 instead of
  showing a green lie.

Never fail silently:
- 30 swallowed exceptions across 19 files now log WARNING with the concrete
  cause AND consequence — plus the token guards in _workout_sync that returned
  [] in total silence, the exact bug that hid the dead sync for weeks.
- New pre-commit hook (scripts/check_silent_failures.py) fails any except that
  neither re-raises nor logs loudly. ruff's S110 only catches `except: pass`;
  the real pattern here was `except: log.info(); return []`. Documented in
  CLAUDE.md.

Verified on real data: the Jul 13 run (pulled from its actual TCX over ADB) now
counts alongside that day's manual squash, and all three workouts are published
to kuhyx/syncs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
2026-07-17 09:22:31 +02:00
e92a74c982 Add change explanation: flutter-coverage-to-100
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
Interactive walkthrough of the coverage grind that unblocked the push
gate: the FakeAsync vs real-loop tension, the runAsync tap technique,
lazy-ListView coverage, and justified coverage:ignore. Includes a
zone-simulator micro-world and a debiased quiz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
2026-07-13 14:42:04 +02:00
a0923ce694 Fix flutter analyze infos for the push gate
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
Document the now-public ServerAddressTile.addresses field, wrap the prod
DB-path ignore in a start/end block to avoid an >80-char line, and reflow
a pre-existing >80-char comment in sync_service. flutter analyze is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
2026-07-13 14:28:45 +02:00
79c7d8d1c6 Cover workout_screen.dart to 100%
Add live-workout interaction tests: set-tap breaks + Skip, warmup break,
rep decrement recompute, long-press reset, in-workout threshold change,
finish -> summary dialog, reset-confirm clears the session, and restoring
an in-progress break through to _onBreakFinished. Interaction taps run
under runAsync so their sqflite writes/break timers stay on the real loop.
coverage:ignore the Android/iOS-only Vibration.vibrate call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
2026-07-13 14:26:12 +02:00
a27efefefc Cover history_screen.dart to 100%
Add an exercise drill-down test (dropdown select -> streak stats card,
weight chart, exercise calendar nav, session tiles with warmup/reps
variants) and a same-date test for the chart painter's tRange==0 branch.
coverage:ignore the _load exercise-state branch (unreachable — selection
goes through _pickExercise, not _load).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
2026-07-13 14:10:21 +02:00
e14cdf9997 Cover settings_screen.dart to 100%
Add tests for the threshold selectors (success + fail persistence), the
debounced weight write, and three device-flow branches that were untested:
token-save failure (throwing secure storage), verification failure, and
the transient pending "verifying" status tile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
2026-07-13 14:03:21 +02:00
5fe4222dfd Cover home_screen.dart to 100%
Cover the post-navigation reload paths (return from workout via Resume,
return from settings), the resume-button closure, and the empty-address
placeholder. Expose ServerAddressTile so its no-address fallback can be
unit-tested directly (the test host always has network interfaces).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
2026-07-13 13:57:10 +02:00
b76d156ae3 Cover storage_service.dart to 100%
Migration (v1->v3), reps-increment at weight cap, and restore-from-backup
paths were untested. Add targeted unit tests plus a file-db test hook
(resetForTesting dbPath) so onUpgrade actually runs. coverage:ignore the
prod DB path and the defensive null-state branch (unreachable — seeding
guarantees a state row).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWkAKQ48Jhg5iSPkzh8NsH
2026-07-13 13:49:38 +02:00
a5ea2ff206 Cover home_screen navigation/resume flows toward 100%
Add widget tests for the manual-workout icon navigation, starting a workout
(navigates to WorkoutScreen), and auto-resume of a saved active session.
home_screen down to 4 uncovered lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
2026-07-13 13:32:01 +02:00
0d0e6e4481 Cover services + app bootstrap toward 100% flutter coverage
Pre-existing coverage gaps, unrelated to the manual-workout feature, blocking the
100% pre-push gate:

- widget_test: exercise WorkoutApp's app-lifecycle handler (paused/detached/
  resumed/inactive/hidden) via handleAppLifecycleStateChanged.
- http_server_service: add a resetForTesting seam and test the bind-in-use
  SocketException swallow plus the /workout HTTP endpoint (no-data 404, data 200,
  unknown-path 404). coverage:ignore the /sdcard-file-read branch (only reachable
  on a real device).
- backup_service: coverage:ignore the Android permission wrappers and the
  defensive readSyncToken catch.
- main(): coverage:ignore the platform-channel app bootstrap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
2026-07-13 13:27:18 +02:00
5b3b15f73c Mark Android-only external-storage blocks coverage:ignore
getExternalStorageDirectory() is unavailable on the Linux test host, so the
Platform.isAndroid fallback blocks in sync_service/http_server_service can't be
exercised in CI. Wrap them in coverage:ignore (honored by flutter test
--coverage) so the 100% gate doesn't demand coverage of an Android-only path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
2026-07-13 13:18:19 +02:00
77151c0013 Manual-workout phone sync P5: phone form + budget + push
The phone can now log an off-app workout that syncs to the PC and counts toward
the unlock.

- lib/screens/manual_workout_screen.dart: the form, mirroring the PC manual
  form (sport TT/other + sport-specific fields, times, location/transport/cost/
  reservation phone, RPE slider, techniques/warm-up/pain, reflections). Shows
  the shared 2/week + 5/month budget (pull-only, computed over the merged
  cross-device record set) and refuses to submit when exhausted. On submit it
  validates with validateManualWorkout, builds the shared sync record, and
  pushes it to devices/phone/log.json.
- workout_sync_service.dart: pushManual(record) and readMergedManualPayloads()
  (pull-only budget read — never writes).
- models/manual_workout.dart: ManualBudget + countManualBudget over the merged
  payloads, windowed by each payload's own date.
- home_screen.dart: a "Log manual workout" app-bar action.

All new lib code is 100% covered (screen widget tests via an injected fake sync
service; sync-service MockClient tests; model unit tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
2026-07-13 13:14:42 +02:00
6f2ec1fa86 Manual-workout phone sync P4: Dart model + validation
Dart port of the Python manual-workout model so the phone can build the same
signed-on-ingest sync payload the PC form produces.

- lib/models/manual_workout.dart: ManualWorkoutDraft (mirrors the Python
  dataclass), validateManualWorkout (ports validate_manual_workout: required
  fields, HH:MM end-after-start, min duration, RPE range, per-sport rules,
  reflection length), buildEntry/buildSyncPayload (byte-for-byte equivalent of
  the Python build_entry + kind/date), manualSyncRecordId, buildManualRecord.
  The MANUAL_WORKOUT_* constants are inlined here (a const-only file has no
  coverable lines and trips the 100%-coverage file-presence guard), replacing
  models/manual_workout_constants.dart.
- test/models/manual_workout_test.dart: the cross-language wire fixture (an
  identical literal to test_manual_workout.py::TestSyncWireFormat — this catches
  key-name/format drift between the two serializers) plus full validation
  coverage. 29 tests; manual_workout.dart is 100% covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
2026-07-13 12:55:05 +02:00
7722260eeb Fix workout_app service tests on non-Android hosts
Pre-existing failures unrelated to the manual-workout feature, exposed once the
pre-push flutter gate started running the Dart suite:

- http_server_service / sync_service: getExternalStorageDirectory() throws
  UnsupportedError (an Error, not an Exception) on non-Android platforms, so the
  `on Exception` fallbacks didn't catch it and it escaped, failing start() and
  writeWorkoutResult(). External storage is Android-only, so guard the call with
  Platform.isAndroid instead of trying to catch a platform Error.
- sync_service_test: the "updates HttpServerService" assertion expected compact
  JSON ("workout_type":"A") but the payload is pretty-printed
  ("workout_type": "A"); fixed the expectation to match.

Full workout_app suite (191 tests) now passes on this host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
2026-07-13 12:47:55 +02:00