Pure-Dart TCX codec pinned to the PC's RunnerUp parser by a shared
golden fixture (Dart byte-compare + pytest through the real verifier),
GPS filters and moving-time maths mirroring the PC walk rule, runs /
run_points tables (schema v9), PositionSource seam over geolocator,
location permissions and foreground service type, map/cache deps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
dopamine-ux 05: selectionClick on recording a set or warmup,
mediumImpact + generated chime (music_theory, pinned tag) on
finish, dialog arrival on the slow motion token, AppDuration /
AppCurve in theme.dart, streak card counts up, all of it
collapsing under the OS reduced-motion flag. One Settings
"Sound" switch silences the chime and the break-end cue
(foreground service included, via a silent channel); haptics
stay. Removes the finished dopamine-ux-05 prompt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
The manual form takes a workout date from today to today+7 (never past). The budget is read as of that date: the fullest 7-/30-day window containing it, past and planned entries alike. History lists future manual entries under PLANNED (off the calendar) and deletes them until their day via a crdt-sync tombstone. Re-logging onto a tombstoned id is refused; a failed push keeps the form open with the reason. Shared fixture manual_budget_merge.json gains a 'planned' scenario (top-level expectations unchanged).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mmTBiMpvyfLuEAXc6nDYT
"Deload now" in the exercise settings sheet runs exactly the step a fail
streak would take (targetAfterFailure), resets both streaks, and records a
progression_events row (schema v7, backed up and restored with the rest).
No manual step up: the target still only climbs through finished workouts.
Mid-workout it re-targets the exercise for the current session: untapped
sets move to the new reps, tapped sets keep theirs, and the whole exercise
is recorded at the new weight at Finish. From Settings it cancels pending
weight/reps debounce writes so they cannot undo the deload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KejiqD7y4NXRiPBKQeWHSy
- Warmup is always 6 reps (kWarmupReps): even, one constant.
- Per exercise: rest after success / fail / warmup (0:30-10:00, 15 s
steps; defaults 3:00/5:00/3:00). Schema v6 NOT NULL DEFAULT columns,
synced in the Firebase progression record (lenient for older records).
The notification carries the fail rest so -1 rep re-cuts to it.
- Settings: PER EXERCISE list replaces PROGRESSION THRESHOLDS; each row
opens the workout tile's sheet and pushes progression when it closes.
Claude-Session: https://claude.ai/code/session_01KeVXsWTuwjmZK5uvbw6vqF
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Per exercise: weight / reps / double progression (n=12, m=6), one pure
rule shared by Finish and every preview; streaks and next step shown on
the tile. Warmup is the first circle of the set row and can be turned off.
- Pause an exercise for n days (default 14): shown as paused, recorded as
FAILED every workout so it steps down; skipped by the notification.
- App bar removed: Reset/Finish live in the rest strip, no rest labels.
- Schema v5 (progression_mode, reps_high, reps_low, has_warmup, paused_until),
synced through the Firebase progression record; NULL warmup = plan default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeVXsWTuwjmZK5uvbw6vqF
The between-set break never finished once the phone's screen was off. Three
independent causes (worked through on-device on 2026-09-12; left
uncommitted awaiting the go-ahead, committed here as-is plus one pin bump):
1. The countdown was not wall-clock: `_breakRemaining--` on a
Timer.periodic, which Android throttles and Doze suspends in a
backgrounded isolate, so the counter stalled and _onBreakFinished()
never fired. Now a BreakClock deadline on the wall clock.
2. An expired break was silently dropped on restore (`if (remaining > 0)`
only) — now cued and logged.
3. No notification infrastructure at all: no package, no
POST_NOTIFICATIONS / FOREGROUND_SERVICE / WAKE_LOCK.
Now: flutter_foreground_task runs the countdown in a service isolate that
survives BACK and the screen lock; flutter_local_notifications shows an
ongoing "Workout A · break m:ss / Next: …" notification with −1 rep / Done
actions and a "Break over" alert on its own channel (raw break_end sound,
vibration). Button presses go through a persisted BreakIntent queue the
screen drains on resume, so a press with the app killed is not lost.
Finish and Reset stop the service; a startup reaper handles the crash case.
Verified on the phone with the screen off and locked: service alive and
ticking while Dozing, both notifications on the lock screen, channel
importance 5 with the break_end sound. Not verifiable from adb (receiver is
RECEIVER_NOT_EXPORTED): an actual action-button tap, and a true recents
swipe — see stronglift_replacement/TODO-workout-app-v2.md.
Also fixed en route: the break-end sound had never played in any widget
test (AssetSource needs path_provider, which nothing faked, and the failure
was swallowed by catchError); the queue's prune wrote back a stale list.
This commit additionally:
- takes the freshness gate's bump flutter_local_notifications 22.3.1;
- restores web/package.json to HEAD (the tree held a stale pre-bump copy);
- gives scripts/check_flutter_coverage.sh a FLUTTER_TEST_CONCURRENCY knob:
the default one-isolate-per-core run peaks over 4 GiB and is OOM-killed
under the resource cap. Default behaviour unchanged.
Gate: flutter analyze clean, 511 tests, 3187/3187 lines (100%), pre-commit
green. Not re-installed on the phone for the 22.3.1 pin: no device attached
at commit time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
The app said "manual budget exhausted for this window" (2/2w, 4/10m) while
the PC read 1/2w, 2/10m for the same records. The PC was right; two
independent Dart defects each inflated a window by one.
`_readMergedPayloads` never looked at `Record.deleted`, so the tombstoned
duplicate `manual:2026-08-21T21:00` -- the second of two logged on 08-21,
already deleted -- kept consuming a weekly slot. Suppression is collected
across the whole device union and applied after the merge, not per file:
one device tombstoning a record while another still holds it live must
delete it, and a per-file skip would just let the live copy win. This
mirrors the PC's `_tombstoned_ids`.
`countManualBudget` also counted `manual_workout:2026-08-15`, a bare
`{type, kind, date}` stub with no evidence fields. The PC refuses to ingest
those ("no workout data to ingest; skipping permanently"), so the phone was
charging budget for a workout neither device ever accepted. The filter lives
in the counter rather than the shared reader because `runnerup_verified` and
`pc_workout_verified` payloads legitimately carry no `start_time`, and the
history view must keep showing them.
Covered by a shared literal fixture both languages read, rather than two
independent round-trips: each side's own tests passed while the pair
disagreed, which is exactly how this shipped. Verified by reverting each
guard in turn and watching the twin fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWq9MRemX9ZWvNmnhNWUP2
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
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
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
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
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
First phase of syncing phone-logged manual/off-app workouts to the PC so they
count toward the unlock. This phase only establishes the cross-language wire
contract; no behavior change to existing flows.
- _manual_workout.py: build_sync_payload(draft, date) = build_entry fields +
a `kind="manual_workout"` discriminator + the workout's own `date`; and
manual_sync_record_id(date, start) -> stable `manual:<date>T<HH:MM>` id.
A StrongLifts session payload has no `kind`, so an ingesting PC can route on
it and never mistake a manual for a verified session.
- test_manual_workout.py: TestSyncWireFormat pins the exact literal payload as
the cross-language contract (the Dart workout_app test will reproduce the
same literal byte-for-byte; neither side's own round-trip test catches drift
from the other).
- workout_app/lib/models/manual_workout_constants.dart: hand-maintained mirror
of the 7 MANUAL_WORKOUT_* constants + the kind value (Python stays source of
truth; the shared fixture is the drift tripwire).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
- Add sqflite_common_ffi + very_good_analysis; tighten analysis_options
- Add BackupService for JSON export/import of exercise state
- Add full test coverage: models, screens, services, widgets
- Add scripts/check_flutter_coverage.sh to enforce 100% line coverage
- Add docstrings to ExerciseState fields and storage service
- Minor fixes across screens, widgets, and sync/HTTP services
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VuiPt6GPWkxpLbJFrnfy8U
- Remove automatic rest timer after each set
- Add inline threshold controls (success/fail streaks) on each exercise card
during an active workout
- Settings: auto-save on change with 600 ms debounce; replace Save button
with Reset to defaults
- Fix weight display asymmetry in settings (fixed 72 px width, centred)
- Progress screen (renamed from History): per-exercise view shows streak
counters, weight chart (kg Y-axis, date X-axis, rolling-2 avg for total
volume), exercise-filtered calendar, and per-exercise session tiles
- Total view shows rolling-2-session average volume chart + full calendar
+ all-session list
- Add WorkoutCalendar widget with monthly navigation
- Store warmupDone in ExerciseResult JSON; surface warmup per session tile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Full-featured workout tracker with session persistence, auto A/B cycling,
warmup weights (4/5 of target), settings weight stepper, history + progress
graph, HTTP sync server, and crash-safe active session resume.
Removed per-set break timers per user preference. Dropped audioplayers and
vibration dependencies; updated permission_handler to 12.x to eliminate
two of three KGP build warnings (shared_preferences_android is an upstream issue).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>