# Workouts — Full Review Findings (2026-07-07)

Working document from the four-agent review (UX, correctness, indie-sdlc conformance,
indie-skills currency). Check items off as they're resolved; decisions recorded inline.

**Decisions so far**
- [x] **H1 (watch↔phone clobber): noted, deliberately deferred** — do not fix yet.
- [x] **H2: decided WATCH-ONLY and implemented** — phone estimate path removed (see H2 below).
- [x] **Batch 2 (2026-07-07 evening, all uncommitted):** H2 watch-only, watchOS test target
  (19 tests), UX #5/#8/#9, site status → released, guidebook IndieSync pin → 0.3.0,
  IndieBackup + IndieDiag integrated, build-stamping unified across all four targets.
- [x] **Test-gap closure: done** — 33 new tests (81 total green), fixes for
  M1/M2/M3, L2/L3, Bird Dog seed, dev-tool gating.
- [x] **2026-07-08: everything committed & pushed** — 9 commits on `main`
  (`a93cbc3`…`495fce1`): the other session's library-add feature, the fix+test batch,
  H2 watch-only, watchOS tests, UX #5/#9, Live Activity, IndieBackup, IndieDiag, and
  the changelog/README docs commit. Site (`released`) and guidebook (IndieSync 0.3.0)
  fixes pushed in their own repos. "Uncommitted" annotations below are now historical.
  Still untracked on purpose: this file, and the other session's new morning-mobility
  `Exercise Library/` folders (in progress, not ours to commit).

---

## 1. Correctness & robustness

### HIGH

- [x] *(RESOLVED-FOR-NOW 2026-07-08, uncommitted, in two steps. Step 1 — trigger (a)
  closed by the L1 write-queue work: `ingestFromWatch` gates on `updatedAt` against the
  cache (which mirrors every accepted write immediately, so it upper-bounds any pending
  slot): strictly-older deliveries dropped with an authoritative re-push,
  equal-timestamp echoes ignored, queued-but-unwritten deletes veto like a tombstone.
  Step 2 — trigger (b) narrowed by the "cheap fix": the watch's `WorkoutLogListView`
  now absorbs phone edits while open (`onChange(of: workout.updatedAt)` re-seed,
  strictly-newer guard so an echo or a stale in-flight push never regresses the screen;
  clock skew degrades to a skipped absorb, never a clobber). Hard "primacy baton"
  considered and REJECTED — handoff over the unordered WC channel is its own consensus
  problem, and offline watch editing must stay possible, which reintroduces merge anyway.
  **Durable fix documented, deliberately not built** (revisit if users report vanished
  sets after reconnect): per-log merge — optional per-log `updatedAt` on
  `WorkoutLogDocument` (additive, like startedAt/completedAt), merge incoming docs
  log-by-log instead of replacing, explicit add/remove intents so an absent log is never
  ambiguous. Makes edits to different logs commute; closes the concurrent-offline-edit
  loss that the absorb can't (whole-doc exchange after a disconnection still loses one
  side's batch). Full rationale in the `ingestFromWatch` comment block.)*
  **H1 — Watch↔phone workout edits clobber each other** *(was DEFERRED 2026-07-07)*
  `SyncEngine.swift:215-225, 332-346`; `Workouts Watch App/Connectivity/WatchConnectivityBridge.swift:184-199`
  Saves are last-writer-by-arrival; no `updatedAt` guard anywhere on the apply path.
  Two triggers: (a) the watch mixes `sendMessage` with a `transferUserInfo` fallback,
  which are unordered relative to each other — a failed-then-queued older edit can land
  after a newer one and win; (b) the exclusive-edit lock covers only the phone detail
  editor, not the phone list checkboxes or run flow, and the watch's `WorkoutLogListView`
  seeds its doc once at init with no `onChange(of: workout.updatedAt)` absorb (the phone
  list has one at line 168) — simultaneous two-device use deterministically loses one
  side. **Fix when picked up:** apply-side `updatedAt` guard (or per-log merge), watch
  absorb, single ordered channel for durable updates.

- [x] *(RESOLVED: watch-only, implemented uncommitted — the phone-side MET-estimate write
  path is gone; `WorkoutHealthWriter` → `WorkoutHealthDeleter`, which only best-effort
  deletes legacy phone-authored estimates, now with an auth guard + os.Logger (also closes
  L4). Phone share auth slimmed to `workoutType()`; phone read auth removed entirely.
  Legacy `phoneEstimate` docs still decode and render with their "estimated" caption.)*
  **H2 — HealthKit double-write / orphaned estimate**
  `WorkoutHealthWriter.swift:31, 63-77, 122-169`; `Workouts Watch App/Views/ActiveWorkoutGateView.swift:106-128`
  Dedupe between the phone's MET-estimate and the watch's real `HKWorkout` is only a
  10 s debounce. Watch unreachable/slow at finish → both write (duplicate Move-ring
  credit); when watch metrics later replace the stored UUID, the phone's estimate is
  orphaned in Health and unreachable from the app's delete path. `sweepRecentlyCompleted`
  can re-arm the race at next launch.
  **Options:** (a) watch-only Health writes — simplest, but watchless users lose Health
  integration entirely; (b) ownership-at-start — if the phone launched a watch session
  for this workout, the watch owns the Health write and the phone never estimates it;
  estimate only for workouts that never had a watch session. (b) keeps phone-only value
  and kills the race at the root. Either way: delete a superseded estimate if one exists.

### MEDIUM (fixes in flight via the test-gap agent)

- [x] *(fixed, uncommitted)* **M1 — Timezone re-bucketing orphans the old workout file**
  `Shared/Model/Documents.swift:97-103` + `SyncEngine.swift:335`
  `relativePath` derives `Workouts/YYYY/MM/` from `start` using the device's *current*
  timezone. A tz change that flips `start`'s local month means a later save writes a new
  bucket file and never removes the old one — two files, one ULID (the cache shows one
  row; "Clean Up Duplicates" exists to mop these up). Fix: on save, if the computed path
  differs from the cached `jsonRelativePath`, remove the old file.

- [x] *(fixed, uncommitted — stub re-check added; name guard deliberately NOT added: on a
  legitimate upgrade the seed itself is the live split by that name)* **M2 — `reconcileSeeds`
  `.upgrade` skips the tombstone re-check `.write` does**
  `SyncEngine.swift:592-639`
  `.write` re-checks `stubExists(id:)` + name guard right before writing (628-630);
  `.upgrade` (621-624) doesn't. A user forking a seed during the 5 s settle window can
  get the just-deleted seed transiently resurrected as a duplicate. Self-heals, but
  visible. Fix: symmetric re-check before `writeSeedBytes`.

- [x] *(fixed, uncommitted)* **M3 — Watch never prunes on a legitimately-empty phone push**
  `Workouts Watch App/Connectivity/WatchConnectivityBridge.swift:213-239`
  `applyState` bails on `splits.isEmpty && workouts.isEmpty` *before* the prune loop,
  but the decode-failure case is already handled by the separate nil guard — so this
  guard only suppresses the genuine "user deleted everything" state, leaving phantom
  splits on the wrist forever. Fix: drop the isEmpty guard.

### LOW (fixes in flight)

- [x] *(BUILT 2026-07-08, uncommitted — `WriteBacklog.swift` (pure slot queue + backoff
  policy + sidecar persistence), `SyncEngine` mirror-first save/delete + serial drain
  loop, `SyncStatusBanner` on the root screen, backlog cleared in `beginRestore()` and
  on account wipe (`WorkoutsModelContainer`), flush-on-background in `WorkoutsApp`.
  Also implements H1's phone-side `updatedAt` intake gate in `ingestFromWatch` — stale
  watch deliveries dropped + authoritative re-push, equal-timestamp echoes ignored,
  pending-delete veto honored. Import/reconcile/observer-removal all skip documents
  superseded by a pending slot, and reconcile's prune exempts pending ids. H1's
  watch-side absorb remains deferred.)*
  **L1 — Sync errors visible only in Settings.** `SyncEngine.swift` `report()`,
  `SettingsView.swift:128`. A failed save on the main screen is silent: the view's local
  doc state shows the change, the write fails before the cache upsert, and the edit
  evaporates on navigation with zero feedback. `lastSyncError` renders only in Settings.
  **Agreed design (2026-07-08) — write-through slot queue + tiered banner:**
  route ALL writes (view saves, watch ingest, deletes-as-tombstone-markers) through a
  per-document-id single slot (map id → latest pending doc; per-ID, NOT per-type — two
  different offline-edited docs must not evict each other); the drain loop serializes
  writes per id, so in-flight races are structurally impossible and a failed write just
  stays in its slot; slot replacement compares `updatedAt` and keeps the newer doc —
  which is a contained version of H1's phone-side apply guard for free (watch-side
  absorb still separate/deferred); clear slots in `beginRestore()` + account wipe;
  persist slots (Application Support, not the iCloud container) with a ~24 h TTL.
  Banner tiers: 0 = silent immediate retries (~10 s); 1 = calm "Changes pending —
  retrying", clears on success; 2 = prominent fault → Diagnostics, on backoff
  exhaustion OR immediately for known-unrecoverable errors (disk full, container gone).
- [x] *(fixed, uncommitted)* **L2 — `report()` uses `print` not the engine's `os.Logger`.**
- [x] *(fixed, uncommitted — `end` now stamped only on the transition into completed)*
  **L3 — `recomputeStatusFromLogs` re-stamps `end = Date()` on every resolve.**
- [x] *(fixed with H2 — auth guard + os.Logger in `WorkoutHealthDeleter`)*
  **L4 — `deleteFromHealth` had no auth check and swallowed all errors.**
- [x] *(fixed, uncommitted — second Bird Dog → Crunch 3×12, regenerated via the script;
  split ULID unchanged, other seeds byte-identical)* **Seed data bug — "Bodyweight Core"
  lists Bird Dog twice.**
- [x] *(fixed, uncommitted — wrapped in `#if DEBUG`)* **"Developer → Clean Up Duplicates"
  ships un-gated to all users.**

### Test gaps (closure in progress)

- [~] `SyncEngine` orchestration — only pure logic covered (`currentSplitID` redirect +
  the planners); real save/reconcile paths need an injection seam for `DocumentFileStore`
  (resolved inside `connect()` today) — deliberate scope stop, noted in `SyncEngineTests.swift`.
- [x] Workout mapper round-trip — `WorkoutDocumentMapperTests.swift` (4 tests: full field
  fidelity incl. machineSettings/metrics/hrZoneSeconds, log order, by-id reconciliation).
- [x] Status machine — `WorkoutStatusMachineTests.swift` (11 tests incl. L3 regression).
- [x] `WCPayload` — `WCPayloadRoundTripTests.swift` (11 tests, every wire shape incl.
  liveProgress and the nil-vs-empty-vs-corrupt decode contract).
- [x] Month bucketing — `WorkoutPathBucketingTests.swift` (5 tests incl. the M1 condition).
- [x] *(done, uncommitted)* watchOS test target "Workouts Watch AppTests" — 19 tests in 3
  suites: `WatchCacheApplier` (upsert/prune seam extracted from the bridge, incl. the
  empty-push prune fix and nil-decode-must-not-prune), bridge optimistic updates, HR-zone
  math + watch helpers. H1 territory deliberately untested pending the deferral.

**Verified sound (checked, not bugs):** no double-seed from slow metadata at launch
(seeding reads `DocumentFileStore.list()` directly + settle re-check); eviction-safe
reads (unreadable ≠ deleted); tombstone veto on both import paths; observer re-apply
idempotent; HKWorkoutSession double-finish/system-end guarded; account-change and
schema-bump cache wipes handled.

---

## 2. User experience

### Top 10 by impact

- [ ] **1. The "log" is the plan — actuals aren't recordable.** `WorkoutLogDocument`
  holds one planned sets/reps/weight + `currentStateIndex`. Did 10/8/6 with a weight
  bump? Uncapturable. The progression chart plots *planned* weight of completed logs
  (`WeightProgressionChartView.swift:36`), so "progress" only moves when the plan is
  edited. Add per-set actual entries (`[SetEntry]` reps/weight/RPE) captured on the work
  page; drive chart + volume from actuals. *The identity-defining fix for a tracker.*
- [ ] **2. No custom exercises** — every add path funnels through `ExercisePickerView`
  listing only the 47 bundled `ExerciseMotionLibrary.exerciseNames`
  (`ExercisePickerView.swift:26`; `ExerciseAddEditView.swift:60-75`). Allow free-text
  names; degrade gracefully to no figure (the figure slot already handles absence).
- [ ] **3. Integer-only weight; unit switch relabels without converting.** Weight is
  `Int` (`Documents.swift:49,151`), wheels step by 1 (no 2.5/1.25 for kg/micro-loading),
  and toggling Weight Unit turns `45 lb` into `45 kg` silently (`Enums.swift:96-116`).
  Store `Double`, offer fractional steps, convert on unit change.
- [ ] **4. Exercise picker is a flat 47-row list** — no search, no grouping, no figure
  thumbnails or target chips, despite the app owning rich per-exercise content. Add
  `.searchable` + group by target/equipment.
- [x] *(fixed, uncommitted — shared `StartedWorkoutNavigator` view modifier now drives
  both start paths; sheet dismisses, parent polls the cache and pushes into the workout)*
  **5. "Start New" dumps you back on the log list.**
- [ ] **6. Splits live three taps deep in Settings** (gear → Library → Splits). Routine
  management needs a primary home on the main screen.
- [ ] **7. Watch can't start a workout; offline Refresh is a silent no-op.**
  `ActiveWorkoutGateView.swift:142-154`; `requestSync()` is reachability-gated and
  returns silently (`WatchConnectivityBridge.swift:109-114`). Allow starting a cached
  split from the wrist (queue via `transferUserInfo`); give Refresh a "Phone
  unreachable" state.
- [x] *(done, uncommitted — new "Workouts Widget" extension target: lock-screen +
  Dynamic Island compact/minimal/expanded, driven by the same run-flow transitions as the
  watch's `LiveProgress` mirror; wall-clock `Text(timerInterval:)` countdowns so no push
  updates; stale activities cleaned at launch; degrades silently when disabled. Bonus:
  build stamping unified via a `scriptAnchors` YAML anchor across all four targets, fixing
  the watch-widget CFBundleVersion warning.)* **8. No Live Activity / Dynamic Island.**
- [x] *(fixed, uncommitted — checkbox gets label/value/hint; HR-zone bar is one spoken
  element + visible numbered legend (no longer color-only); run-flow display fonts are
  `@ScaledMetric` (Dynamic Type); figure `accessibilityHidden(true)`; CalendarListItem
  reads as one full date; Settings gear labeled)* **9. Accessibility gaps on custom
  controls.**
- [ ] **10. No real progress/history** — one per-exercise planned-weight chart; no PRs,
  streaks, volume trend, calendar, workout counts. The main between-session reason to
  open a fitness app.

### Other UX findings by area

**First-run:** iCloud gate copy is best-in-class — keep. Nothing tells a new user that
4 starter splits exist or where they live; add a one-time hint.

**Workout flow:**
- [ ] Active workout looks like any other row — no pinned "Resume" banner (`WorkoutLogsView.swift:33-38`).
- [ ] `PlanEditView` load-type control omits "None" (bodyweight logs can't stay bodyweight; `PlanEditView.swift:65-70`); its ranges disagree with `ExerciseAddEditView` (sets 1...7 vs 0..<20, reps 1...40 vs 0..<100). Unify.
- [ ] `ExerciseAddEditView` allows a meaningless 0-set exercise (`ForEach(0..<20)`, line 82).
- [ ] Rest is one global setting; no per-exercise rest.
- [ ] No haptic on the list checkbox (feedback only exists inside the run flow).
- [ ] Notes only reachable from the exercise detail screen, not the run flow where you'd jot "felt heavy."

**Split/exercise management:**
- [ ] Two near-duplicate exercise-editing surfaces (`SplitDetailView` inline list vs `SplitAddEditView` → `ExerciseListView`) with different subtitle formats. Collapse to one.
- [ ] "What is a Split?" explainer renders on every visit (`SplitDetailView.swift:61-64`) — make it empty-state-only or dismissible.
- [ ] Reorder exists but has no visible drag affordance.
- [ ] Adding one exercise via multi-select picker silently takes 3×10×40 defaults; no plan-editor handoff (abandoned intent noted at `SplitDetailView.swift:190-192`).

**Watch:** live mirroring/Always-On/HR zones are strong. Only a launcher complication
exists — an accessory-rectangular live complication (set N of M, rest countdown) would
be high value.

**Settings:**
- [ ] Workout-section footer only describes auto-finish and says "the watch" though it now applies to phone too (`SettingsView.swift:53-55`).
- [ ] No unit-conversion warning; no backup/export entry (see IndieBackup below).
- "Restore Starter Splits" inline confirmation count is a good pattern — extend to other bulk actions.

**Missing table-stakes features (backlog candidates):** supersets/circuits, warm-up
sets, plate math, rest-day/streak tracking, Siri/App Intents ("start Leg Day"), iPhone
home/lock-screen widgets, bodyweight tracking. (Duration + volume for finished workouts
are already done well.)

**Accessibility (additional):** `CalendarListItem` reads as three disjoint VoiceOver
elements (`CalendarListItem.swift:23-31`).

---

## 3. Release & SDLC conformance

Verified clean: XcodeGen setup, Swift 6 strict concurrency zero-warning build, 48/48
tests green (fresh DerivedData, pinned sim), build stamping live, IndieSync 0.3.0 /
IndieAbout 0.2.2 pinned by semver and matching heads, entitlements exactly per the 2.0
architecture, changelog/README/LICENSE compliant, release pipeline per spec, web page +
stoop present, git hygiene clean.

- [ ] **Ship state: 2.0 is live; ~11 months unshipped.** ASC shows one version, 2.0
  `READY_FOR_SALE`, last build 2025-08-09. Local is 2.2 with three unshipped changelog
  months (exercise library, machine settings, schema v2/v3). Decide when to cut 2.2.
- [x] *(fixed, uncommitted in the Websites repo — `status: released`, matching sibling
  apps and the zod schema)* **Site status field wrong.**
- [ ] **CLAUDE.md Build section stale** (repeat finding, already logged in APPS.md): no
  pinned `xcodebuild build`/`test -destination 'id=815B7FED-…'` lines, no
  `## Key Dependencies` section, stray HTML comment under the title.
- [x] *(done, uncommitted — IndieBackup 2.0.0; `backupRoot` = the container's `Documents/`
  incl. `Stubs/` tombstones so restores can't resurrect deletes; restore tears down the
  MetadataObserver behind a new `SyncEngine.isRestoring` guard (connect/reconcile bail
  during restore), then rebuilds the cache via reconcile and re-baselines a fresh
  observer; `BackupsSectionView` in Settings; `.workoutsbackup` doc type + `onOpenURL`
  tap-to-restore)* **IndieBackup not integrated.**
- [ ] **SyncEngine pre-connect writes silently no-op** instead of throwing
  `SyncError.iCloudUnavailable` (architecture.md invariant 5). Outcome holds via the
  RootGateView hard gate; flag only if SyncEngine is touched again.
- [x] *(fixed, uncommitted in the indie-sdlc repo — APPS.md pin corrected to 0.3.0 with
  the `prune(exempting:)` rationale; componentry.md table gained the 0.3.0 entry, verified
  against the indie-sync 0.3.0 tag message)* **Guidebook-side drift.**

---

## 4. indie-skills currency

Up to date (verified): icloud-sync-engine (IndieSync 0.3.0 = latest tag),
indie-about (0.2.2 = latest), app-changelog (one-liner rule compliant),
swiftui-app-skeleton (Workouts is the skill's canonical example), indie-website
(decommissioned-into-indie.rzen.dev model), stoop-space (config + frontmatter live),
skill deploy mechanism, app-versioning (functionally current).

- [ ] **appstore-publish — behind (skill changed 2026-07-07):** add
  `Scripts/metadata/app/pricing.json` (`{"baseTerritory":"USA","price":"0.00"}`) and
  `availability.json` (`{"availableInNewTerritories":true,"territories":"ALL"}`) or the
  next metadata push won't manage price/territories.
- [x] *(done, uncommitted — five reference files copied to `Workouts/Diagnostics/`;
  `SchemaSkipScanner` adapted with a per-file ceiling resolver because Workouts has two
  independent document schema versions (Splits=2, Workouts=3; Stubs never counted);
  Diagnostics row inside the Settings iCloud Sync section: account/container, download/
  eviction state, schema-skipped count with "update the app" hint, network readiness,
  Copy Diagnostics)* **indie-diag — not integrated.**
- [x] *(done — see §3)* **indie-backup — not integrated.**
- [x] *(done with the Live Activity work — `scriptAnchors` anchor, all four targets stamp)*
  **app-versioning nits.**

---

## Suggested order

1. Bug fixes + tests (in flight): mediums/lows above, Bird Dog seed, dev-tool gating,
   test gaps — excluding H1 (deferred) and H2 (pending decision).
2. Cheap catch-ups: pricing/availability JSONs, site status field, CLAUDE.md Build
   section, weight-unit conversion.
3. Ship 2.2.
4. Feature work by impact: per-set actuals → custom exercises + picker search → splits
   on the main screen + Start New navigation → Live Activity → IndieBackup + IndieDiag →
   progress/PR view → watch-initiated workouts → accessibility pass.
