diff --git a/PLAN-UX1-per-set-actuals.md b/PLAN-UX1-per-set-actuals.md new file mode 100644 index 0000000..87e09dc --- /dev/null +++ b/PLAN-UX1-per-set-actuals.md @@ -0,0 +1,203 @@ +# Plan — UX #1: Per-set actuals (`SetEntry`) + +From REVIEW-2026-07-07.md §2 item 1: today `WorkoutLogDocument` stores only the *plan* +(sets/reps/weight snapshot) plus a progress cursor (`currentStateIndex`). What the user +actually did is never recorded, and the "Weight Progression" chart plots planned weight — +it only moves when the plan is edited. This plan adds per-set actual entries, captured +with zero added friction (log-by-default, correct-by-exception), and drives chart + +volume from actuals. + +**Decisions already made (in session with the user — don't relitigate):** +- Per-set actuals as an optional `[SetEntry]` on `WorkoutLogDocument`. +- `SetEntry` weights are `Double`, and the plan-side `weight` fields flip Int → `Double` + in the same schema rev (the storage half of UX #3, so we don't pay two bumps). UX #3's + *UI* work (fractional pickers, unit conversion) stays out of scope. +- Reserve an optional per-log `updatedAt: Date?` on `WorkoutLogDocument` now (unused) — + it's the field H1's documented per-log merge needs; adding it in this rev spares a + third rev later. +- No RPE. No post-hoc set editing outside the run flow (follow-up). +- Capture UX: auto-append a `SetEntry` pre-filled with planned values when a set + completes; the rest page (and finish page) offer a small "adjust" affordance. + +--- + +## Phase 0 — Schema & plumbing + +New persisted fields touch **three places** (CLAUDE.md rule): the Codable document, the +`@Model` cache entity, and *both* mapper directions. Missing any one silently drops the +field on a phone↔watch round trip (phone pushes entity→doc, watch echoes doc back). + +### `Shared/Model/Documents.swift` +- New Codable struct (next to `MachineSetting`): + ```swift + /// One performed set. Which fields are set follows the log's LoadType: + /// weight → reps + weight; none (bodyweight) → reps; duration → seconds. + struct SetEntry: Codable, Sendable, Equatable { + var reps: Int? = nil + var weight: Double? = nil + var seconds: Int? = nil + var completedAt: Date + } + ``` +- `WorkoutLogDocument`: add `var setEntries: [SetEntry]? = nil` (nil = legacy file / + nothing recorded) and `var updatedAt: Date? = nil` (reserved for H1 per-log merge — + document that in a comment; nothing writes it yet). +- `WorkoutLogDocument.weight` and `ExerciseDocument.weight`: `Int` → `Double`. + JSON decode of existing integer weights into `Double` is native — no read migration. +- **Schema bumps** (both documents — an older app decoding `45.5` into `Int` fails, and + an older app rewriting a file would strip `setEntries`; actuals are irreplaceable, + same rationale as the v2 `metrics` bump): + - `SplitDocument.currentSchemaVersion` 2 → 3 + - `WorkoutDocument.currentSchemaVersion` 3 → 4 + +### Capture invariant lives in the model, not the views +Extend `WorkoutLogDocument.transition(to:)` (Documents.swift:203) — every status flip on +both platforms already funnels through it: +- `.completed` → fill `setEntries` up to `sets` with plan-derived entries (only the + *missing* tail; never overwrite recorded ones). Covers the phone list checkbox, the + watch checkbox, and `completeExercise` in both run flows with one implementation. +- `.notStarted` → `setEntries = nil` (covers `resetExercise` and checkbox un-complete). +- `.skipped` / `.inProgress` → leave entries alone (a skipped log keeps its partial + entries — correct for "End Workout, keeping progress" / `endKeepingProgress`). +- Add a helper for the fill: `mutating func fillSetEntries(upTo count: Int, at date: Date)` + building plan-default entries per `LoadType` (weight → reps+weight, none → reps, + duration → seconds). Unit-testable pure logic. + +### `Shared/Model/Entities.swift` +- `Exercise.weight`, `WorkoutLog.weight`: `Int` → `Double` (SwiftData handles it — the + cache is wiped on version bump anyway, see below). +- `WorkoutLog`: add `var setEntries: [SetEntry]?` and `var logUpdatedAt: Date?` + (`updatedAt` may collide with future needs; pick a name and mirror it in the mapper). + `[MachineSetting]?` on the same entities is the precedent for storing a Codable array. + +### `Shared/Model/Mappers.swift` +- Both directions for both new fields + the Double weights (entity→doc around lines + 19/37, doc→entity around lines 109–119 and 167–178). + +### `Shared/Persistence/WorkoutsModelContainer.swift` +- `currentSchemaVersion` 4 → 5 (wipes + rebuilds the cache from files). + +### `SCHEMA.md` +- Update both document tables (new versions + rationale in the `schemaVersion` notes + row, `weight` type change, `setEntries`, per-log `updatedAt`), add a `SetEntry` table. + Memory/repo rule: SCHEMA.md must track code. + +### Starter seeds +- `weight` is now `Double`; regenerate seeds with `Scripts/generate_starter_splits.swift` + (script's `schemaVersion` output bumps to 3; integer literals still compile into + `Double` params). **Check**: `DocumentCoder`/JSONSerialization should still encode + integral doubles as `45`, not `45.0` — if bytes change beyond `schemaVersion`, that's + fine (reconcile's semantic compare + fixed ULIDs handle the upgrade), just confirm the + new bytes are deterministic across runs. +- `Shared/Screenshots/ScreenshotSeed.swift` compiles as-is (Int literals → Double), but + give completed logs plausible `setEntries` so marketing screenshots show actuals. + +### Weight formatting +- `WeightUnit.format(_ value: Int)` (Enums.swift:105) → add/replace with a `Double` + overload that trims a trailing `.0` ("45 lb", "42.5 kg"). Fix call sites + (`Exercise.planSummary`, summary views, watch views — grep `weightUnit.format` and + `.abbreviation`). + +**Phase 0 exit:** both targets build, existing tests updated for Double weights, cache +rebuild verified in sim (old store wiped, files re-imported). + +--- + +## Phase 1 — Capture in the run flows + +### Phone: `Workouts/Views/WorkoutLogs/ExerciseProgressView.swift` +- `recordProgress(for:)` (line 557): when `currentStateIndex` advances n → m, append + plan-default entries for sets n+1…m (`completedAt: Date()`). Monotonic guard already + prevents double-appends; keep entries append-only here (swiping back never removes). +- `addSet()` (One More, line 533): already marks prior sets complete via + `currentStateIndex` — route its entry fill through the same helper so the bonus set's + entry appears when *it* completes, not when added. +- `completeExercise()` / `resetExercise()`: no extra work — `transition(to:)` now owns + fill/clear (Phase 0). +- **Adjust affordance**: on the **rest page** and **finish page**, under the timer, + show the just-completed set's entry as a tappable pill — e.g. "10 reps · 45 lb" with + a subtle edit glyph. Tapping opens a compact sheet (or inline steppers): reps stepper, + weight stepper in 2.5 lb / 1.25 kg increments (display unit from `@AppStorage + weightUnit` — stored value is unit-less, same as today), duration variant shows + seconds. Writes back to the last `SetEntry`, stamps `doc.updatedAt`, calls + `onChange()`. Keep it one-hand-dismissable; this is dead time by design, don't block + the rest countdown or auto-advance. +- `CompletedPhaseView` (line 761): when the log has recorded entries, show the actual + line per set (e.g. "10 · 8 · 6 reps @ 45 lb", or per-set list if mixed weights) + instead of / beneath the plan summary. + +### Watch: `Workouts Watch App/Views/ExerciseProgressView.swift` +- Mirror the `recordProgress` / `addSet` entry appends (mutations at ~lines 480–545). + `transition(to:)` changes arrive for free (shared source). +- **No adjust UI on the watch in v1** — planned values auto-log; adjustments happen on + the phone. (Crown-driven adjust is a follow-up.) Note: when both devices mirror a run, + only the driving device makes the durable write — unchanged, `LiveProgress` carries no + entry data and needs no changes. + +### Checkbox paths +- Phone `WorkoutLogListView.swift` (~lines 284–290) and the watch list equivalent: + no code change expected beyond what `transition(to:)` provides — verify complete / + un-complete / skip cycling produces sane entries. + +**Phase 1 exit:** run a workout in the sim deviating from plan; file JSON shows the +deviation; reset clears; One More records the bonus set; checkbox-complete synthesizes +full plan entries; watch run auto-logs. + +--- + +## Phase 2 — Drive reads from actuals + +Fallback rule everywhere: `setEntries` when non-empty, else synthesize from the plan for +completed legacy logs. Add one shared helper on `WorkoutLogDocument` (e.g. +`effectiveSetEntries`) rather than per-view logic; mirror on the `WorkoutLog` entity or +map through the document. + +- **Chart** `Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift:36–38`: plot + the **top-set actual weight** per completed log (`setEntries` max weight, fallback + planned `weight`). Keep the query as-is (entity now carries `setEntries`). +- **Volume** `Shared/HealthKit/HealthKitMapping.swift:33` `WorkoutVolume.total`: + Σ reps×weight over effective entries for weighted logs (fallback = old + sets×reps×weight formula, unchanged result for legacy). Callers — + `WorkoutSummaryView.swift:82` and watch `ActiveWorkoutGateView.swift:120` + (`metrics.totalVolume` at session end) — inherit the fix. +- **Summary** `WorkoutSummaryView`: where per-exercise lines show the plan, prefer + actuals ("10 · 8 · 6 @ 45 lb"). + +**Phase 2 exit:** chart moves after a heavier-than-plan workout without touching the +split; legacy workouts still chart/volume identically to before. + +--- + +## Tests (extend existing suites; keep 81+ green) + +- `WorkoutDocumentMapperTests`: round-trip `setEntries`, per-log `updatedAt`, Double + weight (fractional value survives doc→entity→doc). +- New `SetEntryTests` (or fold into `WorkoutStatusMachineTests`): `transition(to:)` + fill-on-complete (only missing tail), clear-on-notStarted, skip preserves partials; + `fillSetEntries` per LoadType; `effectiveSetEntries` fallback synthesis. +- `WorkoutVolume`: actuals sum, mixed actual/legacy logs, legacy-only equals old formula. +- Decode fixture: a v3 workout JSON (Int weight, no `setEntries`) decodes; forward-gate + test covers v4 quarantine by older logic if such tests exist (check `SeedLibraryTests` + / IndieSync gate tests). +- Watch target: `WatchCacheApplier` round trip includes the new fields. +- `WorkoutPathBucketingTests` etc. untouched but re-run. + +## Non-goals (explicitly out) + +- RPE / effort tracking. +- UX #3 UI: fractional plan pickers, kg/lb conversion on unit toggle (only the Double + *storage* lands here). +- Post-hoc editing of entries outside the run flow. +- H1 per-log merge (field reserved, no behavior). +- Watch adjust UI. + +## Verification + +- `xcodegen generate` not needed unless targets/files change in `project.yml` (new + source files under existing groups are picked up by directory — confirm; if any new + file isn't, regen). +- Build phone + watch schemes, run both test targets. +- Manual: fresh install (cache rebuild), legacy file import, full run-flow pass on both + devices, checkbox pass, chart before/after, screenshot seed still renders. +- Changelog: one end-user entry (per `app-changelog` skill), e.g. recording what you + actually lifted per set and the progress chart now tracking it. SCHEMA.md updated. diff --git a/README.md b/README.md index 4fd64bc..4c21aa7 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,13 @@ your own iCloud Drive. - **Machine comfort settings** — machine-based exercises remember your setup (seat height, back-rest position, pin position, …), shown on the workout screen and editable mid-workout; changes save back to the split for next time. +- **Per-set actuals** — every completed set is recorded automatically with its + planned reps and weight, and the rest and finish pages offer a small adjust + pill to correct what you actually did (in 2.5 lb / 1.25 kg steps) without + breaking the flow; the completed-exercise page shows the recorded sets. - **Progress tracking** — weight-progression charts per exercise across past - sessions. + sessions, plotting the top-set weight you actually lifted; workout volume + sums the recorded sets too. - **Apple Watch companion** — starting a workout on the iPhone launches the watch app straight into it. The watch lists your in-progress workouts; pick one, pick an exercise, and run it as a paged flow: a **Ready?** lead-in, count-up work phases, diff --git a/REVIEW-2026-07-07.md b/REVIEW-2026-07-07.md new file mode 100644 index 0000000..5680e27 --- /dev/null +++ b/REVIEW-2026-07-07.md @@ -0,0 +1,312 @@ +# 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. diff --git a/SCHEMA.md b/SCHEMA.md index 72e8ed6..a387903 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -25,11 +25,11 @@ SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by --- -## SplitDocument — `currentSchemaVersion: 2` +## SplitDocument — `currentSchemaVersion: 3` | Property | Type | Optional | Notes | |---|---|---|---| -| `schemaVersion` | Int | | Forward-compatibility gate. Bumped 1→2 when the weight-reminder fields and `category` were removed from `ExerciseDocument` and `machineSettings` was added — removing required fields means older apps can't decode new files, so they must quarantine, not rewrite | +| `schemaVersion` | Int | | Forward-compatibility gate. Bumped 1→2 when the weight-reminder fields and `category` were removed from `ExerciseDocument` and `machineSettings` was added — removing required fields means older apps can't decode new files, so they must quarantine, not rewrite. Bumped 2→3 when `ExerciseDocument.weight` went Int → Double — an older app decoding a fractional weight into `Int` fails, so it must quarantine | | `id` | String | | ULID | | `name` | String | | | | `color` | String | | Theme color name | @@ -49,16 +49,16 @@ SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by | `order` | Int | | Sort position within the split | | `sets` | Int | | | | `reps` | Int | | | -| `weight` | Int | | Unit-less integer; `WeightUnit` only relabels display | +| `weight` | Double | | Unit-less; `WeightUnit` only relabels display. Int → Double in schema v3 (old integer values decode natively) | | `loadType` | Int | | Raw `LoadType` | | `durationSeconds` | Int | | Total seconds; 0 when not a timed exercise | | `machineSettings` | [MachineSetting] | ✓ | Ordered machine comfort settings; nil → not a machine exercise, empty → machine exercise with nothing recorded. Doubles as the machine-based flag | -## WorkoutDocument — `currentSchemaVersion: 3` +## WorkoutDocument — `currentSchemaVersion: 4` | Property | Type | Optional | Notes | |---|---|---|---| -| `schemaVersion` | Int | | Bumped 1→2 when `metrics` was added (captured HR data is irreplaceable, so older apps must quarantine, not strip). Bumped 2→3 when the derived `completed` flag was removed from `WorkoutLogDocument` and `machineSettings` was added — dropping a required field means older apps can't decode new files, so they must quarantine | +| `schemaVersion` | Int | | Bumped 1→2 when `metrics` was added (captured HR data is irreplaceable, so older apps must quarantine, not strip). Bumped 2→3 when the derived `completed` flag was removed from `WorkoutLogDocument` and `machineSettings` was added — dropping a required field means older apps can't decode new files, so they must quarantine. Bumped 3→4 when `WorkoutLogDocument.weight` went Int → Double and per-set actuals (`setEntries`) were added — a fractional weight fails an older decode, and a rewrite would strip the irreplaceable actuals | | `id` | String | | ULID (chronological — drives month bucketing and sorting) | | `splitID` | String | ✓ | Denormalized reference; no live relationship | | `splitName` | String | ✓ | Denormalized snapshot of the split's name | @@ -79,7 +79,7 @@ SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by | `order` | Int | | Sort position within the workout | | `sets` | Int | | Planned sets (snapshot from the exercise) | | `reps` | Int | | Planned reps (snapshot) | -| `weight` | Int | | Snapshot; unit-less integer | +| `weight` | Double | | Snapshot; unit-less. Int → Double in schema v4 (old integer values decode natively) | | `loadType` | Int | | Raw `LoadType` (snapshot) | | `durationSeconds` | Int | | Total seconds; 0 when not a timed exercise | | `currentStateIndex` | Int | | Progress through the set sequence | @@ -89,6 +89,8 @@ SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by | `machineSettings` | [MachineSetting] | ✓ | Snapshot of the exercise's machine settings at plan time; nil → not a machine exercise, empty → nothing recorded | | `startedAt` | Date | ✓ | First move to `.inProgress`. *Not schema-bumped* | | `completedAt` | Date | ✓ | Last move to `.completed`. *Not schema-bumped* | +| `setEntries` | [SetEntry] | ✓ | Per-set actuals in set order; nil → legacy file / nothing recorded. Appended as sets complete (pre-filled from the plan); `transition(to:)` fills the missing tail on `.completed` and clears on `.notStarted`; `.skipped` keeps partials. Added in schema v4 | +| `updatedAt` | Date | ✓ | Reserved for the future per-log merge (H1); nothing writes it yet. Added in schema v4 | ## MachineSetting (embedded in ExerciseDocument and WorkoutLogDocument) @@ -100,6 +102,18 @@ string, not a number, because machine dials aren't uniformly numeric. | `name` | String | | e.g. "Seat Height", "Back Rest Incline" | | `value` | String | | Free-form, e.g. "4", "3rd hole", "45°" | +## SetEntry (embedded in WorkoutLogDocument, schema v4) + +One performed set. Which fields are set follows the log's `LoadType`: +weight → `reps` + `weight`; none (bodyweight) → `reps`; duration → `seconds`. + +| Property | Type | Optional | Notes | +|---|---|---|---| +| `reps` | Int | ✓ | | +| `weight` | Double | ✓ | Unit-less, like the plan snapshot | +| `seconds` | Int | ✓ | Held/worked seconds for duration exercises | +| `completedAt` | Date | | When the set finished | + ## WorkoutMetrics (embedded in WorkoutDocument, schema v2) | Property | Type | Optional | Notes | @@ -145,4 +159,4 @@ veto seed resurrection forever). `watch`, `phoneEstimate` **WeightUnit** (`lb`/`kg`) is a display setting only — it is never persisted in -documents, and stored weight integers are never rewritten when it changes. +documents, and stored weight values are never rewritten when it changes. diff --git a/Shared/HealthKit/HealthKitMapping.swift b/Shared/HealthKit/HealthKitMapping.swift index 716651d..8acd989 100644 --- a/Shared/HealthKit/HealthKitMapping.swift +++ b/Shared/HealthKit/HealthKitMapping.swift @@ -28,13 +28,21 @@ extension WorkoutActivityType { } } -/// Σ sets×reps×weight across weighted logs — the app-native "how much work" number. +/// Σ reps×weight across weighted logs — the app-native "how much work" number. /// Computed in the stored weight unit (display-only units never change the value). +/// Sums recorded per-set actuals when a log has them (via `effectiveSetEntries`, +/// which also synthesizes plan entries for completed legacy logs — identical to +/// the old sets×reps×weight formula); a legacy log with no entries at all falls +/// back to that plan formula unchanged. enum WorkoutVolume { static func total(_ logs: [WorkoutLogDocument]) -> Double { logs.reduce(0) { sum, log in guard LoadType(rawValue: log.loadType) == .weight else { return sum } - return sum + Double(log.sets * log.reps * log.weight) + let entries = log.effectiveSetEntries + guard !entries.isEmpty else { + return sum + Double(log.sets * log.reps) * log.weight + } + return sum + entries.reduce(0) { $0 + Double($1.reps ?? 0) * ($1.weight ?? 0) } } } } diff --git a/Shared/Model/Documents.swift b/Shared/Model/Documents.swift index 240ce2e..3a4ad25 100644 --- a/Shared/Model/Documents.swift +++ b/Shared/Model/Documents.swift @@ -35,7 +35,10 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable { // added. Removing required fields means older apps can no longer decode new // files, so the forward-gate must quarantine them rather than let an older app // silently rewrite them. - static let currentSchemaVersion = 2 + // Bumped 2→3 when `ExerciseDocument.weight` went Int → Double: an older app + // decoding a fractional weight (e.g. 45.5) into `Int` fails, so it must + // quarantine rather than rewrite. + static let currentSchemaVersion = 3 var relativePath: String { "Splits/\(id).json" } } @@ -46,7 +49,7 @@ struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable { var order: Int var sets: Int var reps: Int - var weight: Int + var weight: Double // unit-less; fractional since schema v3 (Int weights decode natively) var loadType: Int var durationSeconds: Int // total seconds (0 when not a timed exercise) /// Ordered, user-defined comfort settings for machine-based exercises (seat @@ -64,6 +67,15 @@ struct MachineSetting: Codable, Sendable, Equatable { var value: String // "4", "3rd hole", "45°" — free-form; machine dials aren't uniformly numeric } +/// One performed set. Which fields are set follows the log's `LoadType`: +/// weight → reps + weight; none (bodyweight) → reps; duration → seconds. +struct SetEntry: Codable, Sendable, Equatable { + var reps: Int? = nil + var weight: Double? = nil + var seconds: Int? = nil + var completedAt: Date +} + // MARK: - Workout struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable { @@ -90,7 +102,12 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable { // `WorkoutLogDocument` and `machineSettings` was added to it. Dropping a // required field means older apps can no longer decode new files, so the // forward-gate must quarantine them. - static let currentSchemaVersion = 3 + // Bumped 3→4 when `WorkoutLogDocument.weight` went Int → Double and per-set + // actuals (`setEntries`) were added: an older app decoding a fractional weight + // into `Int` fails, and one rewriting the file would strip `setEntries` — + // the recorded actuals are irreplaceable (same rationale as the v2 `metrics` + // bump), so older apps must quarantine. + static let currentSchemaVersion = 4 var relativePath: String { Self.relativePath(id: id, start: start) } @@ -153,7 +170,7 @@ struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable { var order: Int var sets: Int var reps: Int - var weight: Int + var weight: Double // unit-less plan snapshot; fractional since schema v4 var loadType: Int var durationSeconds: Int // total seconds (0 when not a timed exercise) var currentStateIndex: Int @@ -168,6 +185,14 @@ struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable { /// Optional additions — absent in files written before they existed. var startedAt: Date? = nil var completedAt: Date? = nil + /// Per-set actuals, in set order. `nil` → legacy file / nothing recorded; + /// entries are appended as sets complete (pre-filled from the plan) and only + /// diverge from it when the user adjusts one. Cleared on reset to `.notStarted`. + var setEntries: [SetEntry]? = nil + /// Reserved for the per-log merge H1's conflict resolution needs (a log edited + /// on two devices merges by the newer `updatedAt`). Nothing writes it yet; + /// absent in older files. + var updatedAt: Date? = nil } extension WorkoutLogDocument { @@ -204,17 +229,58 @@ extension WorkoutLogDocument { status = newStatus.rawValue switch newStatus { case .notStarted: + // A full reset — the run never happened, so recorded actuals go too. startedAt = nil completedAt = nil + setEntries = nil case .inProgress: if startedAt == nil { startedAt = Date() } completedAt = nil case .completed: if completedAt == nil { completedAt = Date() } + // Sets that completed without an explicit record (list checkbox, + // completeExercise) get plan-default entries; recorded ones are kept. + fillSetEntries(upTo: sets, at: completedAt ?? Date()) case .skipped: + // A skipped log keeps its partial entries — "End Workout, keeping + // progress" must not discard the sets that were done. completedAt = nil } } + + /// Append plan-default entries for sets that completed without an explicit + /// record, up to `count` in total. Only the missing tail is filled — entries + /// already recorded are never overwritten — and a zero `count` on a log with + /// no entries leaves `setEntries` nil (nothing was performed). + mutating func fillSetEntries(upTo count: Int, at date: Date) { + var entries = setEntries ?? [] + guard entries.count < count else { return } + while entries.count < count { + entries.append(planSetEntry(completedAt: date)) + } + setEntries = entries + } + + /// One plan-default entry, shaped by the log's `LoadType`: + /// weight → reps + weight; none (bodyweight) → reps; duration → seconds. + func planSetEntry(completedAt date: Date) -> SetEntry { + switch LoadType(rawValue: loadType) ?? .weight { + case .weight: SetEntry(reps: reps, weight: weight, completedAt: date) + case .none: SetEntry(reps: reps, completedAt: date) + case .duration: SetEntry(seconds: durationSeconds, completedAt: date) + } + } + + /// The entries read paths consume — the single fallback rule for legacy files: + /// recorded actuals when any exist, else plan-synthesized entries for a + /// *completed* log (what `fillSetEntries` would have recorded), else empty + /// (an unfinished log with nothing recorded performed nothing). + var effectiveSetEntries: [SetEntry] { + if let setEntries, !setEntries.isEmpty { return setEntries } + guard status == WorkoutStatus.completed.rawValue else { return [] } + let date = completedAt ?? date + return (0.. String { + let text = value.truncatingRemainder(dividingBy: 1) == 0 + ? String(Int(value)) + : String(value) + return "\(text) \(abbreviation)" + } + /// Render a stored weight value with the current unit's label, e.g. "135 lb". func format(_ value: Int) -> String { "\(value) \(abbreviation)" } } diff --git a/Shared/Model/Mappers.swift b/Shared/Model/Mappers.swift index e43a832..c7af71b 100644 --- a/Shared/Model/Mappers.swift +++ b/Shared/Model/Mappers.swift @@ -38,7 +38,8 @@ extension WorkoutLogDocument { durationSeconds: log.durationTotalSeconds, currentStateIndex: log.currentStateIndex, status: log.statusRaw, notes: log.notes, date: log.date, machineSettings: log.machineSettings, - startedAt: log.startedAt, completedAt: log.completedAt) + startedAt: log.startedAt, completedAt: log.completedAt, + setEntries: log.setEntries, updatedAt: log.logUpdatedAt) } } @@ -167,7 +168,8 @@ enum CacheMapper { weight: d.weight, loadType: d.loadType, durationTotalSeconds: d.durationSeconds, currentStateIndex: d.currentStateIndex, statusRaw: d.status, notes: d.notes, date: d.date, machineSettings: d.machineSettings, - startedAt: d.startedAt, completedAt: d.completedAt) + startedAt: d.startedAt, completedAt: d.completedAt, + setEntries: d.setEntries, logUpdatedAt: d.updatedAt) } private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) { @@ -185,5 +187,7 @@ enum CacheMapper { l.machineSettings = d.machineSettings l.startedAt = d.startedAt l.completedAt = d.completedAt + l.setEntries = d.setEntries + l.logUpdatedAt = d.updatedAt } } diff --git a/Shared/Persistence/WorkoutsModelContainer.swift b/Shared/Persistence/WorkoutsModelContainer.swift index bbfcb59..b82a6a4 100644 --- a/Shared/Persistence/WorkoutsModelContainer.swift +++ b/Shared/Persistence/WorkoutsModelContainer.swift @@ -10,7 +10,9 @@ enum WorkoutsModelContainer { /// 3: added `categoryRaw` to `Exercise`. /// 4: removed the weight-reminder columns and `categoryRaw` from `Exercise` and /// the `completed` column from `WorkoutLog`; added `machineSettings` to both. - static let currentSchemaVersion = 4 + /// 5: `weight` went Int → Double on `Exercise` and `WorkoutLog`; added + /// `setEntries` and `logUpdatedAt` to `WorkoutLog`. + static let currentSchemaVersion = 5 private static let schemaVersionKey = "Workouts.persistenceSchemaVersion" private static let identityTokenKey = "Workouts.iCloudIdentityToken" diff --git a/Shared/Screenshots/ScreenshotSeed.swift b/Shared/Screenshots/ScreenshotSeed.swift index a18318f..7ea298c 100644 --- a/Shared/Screenshots/ScreenshotSeed.swift +++ b/Shared/Screenshots/ScreenshotSeed.swift @@ -54,7 +54,7 @@ enum ScreenshotSeed { for (eIndex, e) in s.3.enumerated() { let isDuration = e.0 == "Plank" let ex = Exercise(id: ULID.make(), name: e.0, order: eIndex, sets: e.1, reps: e.2, - weight: e.3, loadType: (isDuration ? LoadType.duration : .weight).rawValue, + weight: Double(e.3), loadType: (isDuration ? LoadType.duration : .weight).rawValue, durationTotalSeconds: isDuration ? 45 : 0) ex.split = split context.insert(ex) @@ -64,7 +64,7 @@ enum ScreenshotSeed { let upper = splits[0] // ---- Past finished sessions (Lat Pull Down trend for the chart) -------- - let liftTrend = [95, 100, 105, 110] + let liftTrend: [Double] = [95, 100, 105, 110] for (i, w) in liftTrend.enumerated() { let date = daysAgo((liftTrend.count - i) * 4) let workout = Workout(id: ULID.make(), splitID: nil, splitName: upper.0, start: date, @@ -72,11 +72,12 @@ enum ScreenshotSeed { createdAt: date, updatedAt: date, jsonRelativePath: "") context.insert(workout) for (eIndex, e) in upper.3.enumerated() { - let weight = e.0 == "Lat Pull Down" ? w : e.3 + let weight = e.0 == "Lat Pull Down" ? w : Double(e.3) let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1, reps: e.2, weight: weight, loadType: LoadType.weight.rawValue, durationTotalSeconds: 0, currentStateIndex: e.1, - statusRaw: WorkoutStatus.completed.rawValue, notes: nil, date: date) + statusRaw: WorkoutStatus.completed.rawValue, notes: nil, date: date, + setEntries: actualEntries(sets: e.1, reps: e.2, weight: weight, at: date)) log.workout = workout context.insert(log) } @@ -91,10 +92,12 @@ enum ScreenshotSeed { let progress = [(4, WorkoutStatus.completed), (2, .inProgress), (0, .notStarted), (0, .notStarted)] for (eIndex, e) in upper.3.enumerated() { let (idx, status) = progress[eIndex] + let weight = e.0 == "Lat Pull Down" ? 110 : Double(e.3) let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1, reps: e.2, - weight: e.0 == "Lat Pull Down" ? 110 : e.3, loadType: LoadType.weight.rawValue, + weight: weight, loadType: LoadType.weight.rawValue, durationTotalSeconds: 0, currentStateIndex: idx, - statusRaw: status.rawValue, notes: nil, date: today) + statusRaw: status.rawValue, notes: nil, date: today, + setEntries: idx > 0 ? actualEntries(sets: idx, reps: e.2, weight: weight, at: today) : nil) log.workout = workout context.insert(log) } @@ -102,5 +105,14 @@ enum ScreenshotSeed { try? context.save() return workout } + + /// Plausible per-set actuals for a weighted log: plan values, with the last + /// set a couple of reps short so screenshots show real-looking variation. + private static func actualEntries(sets: Int, reps: Int, weight: Double, at date: Date) -> [SetEntry] { + (0.. doc.logs[i].currentStateIndex else { return } doc.logs[i].currentStateIndex = reached + // Log-by-default: each newly-completed set gets a plan-filled entry + // (append-only; adjustments happen on the phone — no watch adjust UI in v1). + doc.logs[i].fillSetEntries(upTo: reached, at: Date()) doc.logs[i].transition(to: .inProgress) recomputeWorkoutStatus() diff --git a/Workouts Watch AppTests/WatchCacheApplierTests.swift b/Workouts Watch AppTests/WatchCacheApplierTests.swift index 3c94b6b..a903ab5 100644 --- a/Workouts Watch AppTests/WatchCacheApplierTests.swift +++ b/Workouts Watch AppTests/WatchCacheApplierTests.swift @@ -151,6 +151,30 @@ struct WatchCacheApplierTests { #expect(workoutIDs(in: ctx) == ["01WKA"]) } + // MARK: - New-field fidelity through the watch cache + + /// A pushed workout carrying the v4 log fields — fractional weight, recorded + /// `setEntries`, per-log `updatedAt` — survives apply → cache entity → document + /// intact, so the watch's echo back to the phone can't strip actuals. + @Test func applyRoundTripsSetEntriesAndPerLogUpdatedAt() throws { + let ctx = try makeContext() + var doc = workout(id: "01WKENTRIES") + doc.logs[0].weight = 42.5 + doc.logs[0].status = WorkoutStatus.completed.rawValue + doc.logs[0].setEntries = [ + SetEntry(reps: 10, weight: 42.5, completedAt: Self.ts), + SetEntry(reps: 8, weight: 45, completedAt: Self.ts), + ] + doc.logs[0].updatedAt = Self.ts + + WatchCacheApplier.apply(splits: [], workouts: [doc], into: ctx) + let entity = try #require(CacheMapper.fetchWorkout(id: "01WKENTRIES", in: ctx)) + let rebuilt = WorkoutDocument(from: entity) + #expect(rebuilt.logs[0].weight == 42.5) + #expect(rebuilt.logs[0].setEntries == doc.logs[0].setEntries) + #expect(rebuilt.logs[0].updatedAt == Self.ts) + } + @Test func optionalOverloadReturnsTrueWhenBothSetsDecode() throws { let ctx = try makeContext() // Typed optionals to disambiguate onto the nil-aware overload (an authoritative, diff --git a/Workouts Watch AppTests/WatchHelpersTests.swift b/Workouts Watch AppTests/WatchHelpersTests.swift index 175d9d6..21d9f49 100644 --- a/Workouts Watch AppTests/WatchHelpersTests.swift +++ b/Workouts Watch AppTests/WatchHelpersTests.swift @@ -34,7 +34,7 @@ struct WatchHelpersTests { private static let ts = Date(timeIntervalSince1970: 1_700_000_000) - private func log(id: String, sets: Int, reps: Int, weight: Int, loadType: LoadType) -> WorkoutLogDocument { + private func log(id: String, sets: Int, reps: Int, weight: Double, loadType: LoadType) -> WorkoutLogDocument { WorkoutLogDocument( id: id, exerciseName: "Ex-\(id)", order: 0, sets: sets, reps: reps, weight: weight, loadType: loadType.rawValue, durationSeconds: 0, currentStateIndex: 0, diff --git a/Workouts/Sync/DuplicateCleanup.swift b/Workouts/Sync/DuplicateCleanup.swift index 8783ff0..f1e615b 100644 --- a/Workouts/Sync/DuplicateCleanup.swift +++ b/Workouts/Sync/DuplicateCleanup.swift @@ -82,7 +82,7 @@ enum DuplicateCleanupPlanner { var name: String var sets: Int var reps: Int - var weight: Int + var weight: Double var loadType: Int var durationSeconds: Int var machineSettings: [MachineSettingFingerprint]? @@ -156,7 +156,7 @@ enum DuplicateCleanupPlanner { var order: Int var sets: Int var reps: Int - var weight: Int + var weight: Double var loadType: Int var durationSeconds: Int var status: String diff --git a/Workouts/Views/Exercises/ExerciseAddEditView.swift b/Workouts/Views/Exercises/ExerciseAddEditView.swift index 57990dd..ffaa36b 100644 --- a/Workouts/Views/Exercises/ExerciseAddEditView.swift +++ b/Workouts/Views/Exercises/ExerciseAddEditView.swift @@ -41,7 +41,9 @@ struct ExerciseAddEditView: View { self.exercise = exercise self.split = split - let w = exercise.weight + // The tens/ones pickers step whole values; a fractional stored weight + // shows (and saves back) its whole part until UX #3's UI lands. + let w = Int(exercise.weight) _exerciseName = State(initialValue: exercise.name) _loadType = State(initialValue: exercise.loadTypeEnum) _minutes = State(initialValue: exercise.durationMinutes) @@ -200,7 +202,7 @@ struct ExerciseAddEditView: View { doc.exercises[idx].name = exerciseName doc.exercises[idx].sets = sets doc.exercises[idx].reps = reps - doc.exercises[idx].weight = newWeight + doc.exercises[idx].weight = Double(newWeight) doc.exercises[idx].loadType = loadType.rawValue doc.exercises[idx].durationSeconds = durationSecs // ON → a (possibly empty) array; OFF → nil, discarding any prior settings. diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift index db7bea6..967fc59 100644 --- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift +++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift @@ -28,6 +28,7 @@ import UIKit /// teal for rest, with the current phase drawn as a wider dash. struct ExerciseProgressView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.verticalSizeClass) private var verticalSizeClass /// The shared working workout document owned by the parent list. We mutate the /// matching log in place and ask the parent to persist each change — driving the UI @@ -66,6 +67,12 @@ struct ExerciseProgressView: View { /// end anchor the watch's mirror counts off. @AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5 + /// Display unit for the adjust affordance (stored weights stay unit-less). + @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb + + /// Presents the compact adjust sheet for the just-completed set's entry. + @State private var showingAdjust = false + /// Planned set count for this run. `One More` bumps it (and the log's `sets`). @State private var setCount: Int @State private var currentPage: Int @@ -204,18 +211,28 @@ struct ExerciseProgressView: View { return detail.isEmpty ? setsText : "\(setsText) × \(detail)" } + /// The half-and-half split: top/bottom in portrait, side-by-side in landscape + /// (compact vertical size class), so neither half ends up squeezed into a + /// short rotated band. `AnyLayout` preserves view identity across rotation — + /// the paged flow's position and running timers survive the swap. + private var splitLayout: AnyLayout { + verticalSizeClass == .compact + ? AnyLayout(HStackLayout(spacing: 0)) + : AnyLayout(VStackLayout(spacing: 0)) + } + var body: some View { if startsCompleted { - // Same top/bottom split as the run flow, with the Completed badge where + // Same half-and-half split as the run flow, with the Completed badge where // the paged flow would be, so the form-guide figure stays on screen. - VStack(spacing: 0) { + splitLayout { CompletedPhaseView(log: log) ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "") } .navigationTitle(log?.exerciseName ?? "") .navigationBarTitleDisplayMode(.inline) } else if startsSkipped { - VStack(spacing: 0) { + splitLayout { SkippedPhaseView() ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "") } @@ -227,7 +244,7 @@ struct ExerciseProgressView: View { } private var flowBody: some View { - VStack(spacing: 0) { + splitLayout { // Paged flow — top half. TabView(selection: $currentPage) { ForEach(0.. doc.logs[i].currentStateIndex else { return } doc.logs[i].currentStateIndex = reached + // Log-by-default: each newly-completed set gets a plan-filled entry + // (append-only — swiping back never removes one; adjust corrects the last). + doc.logs[i].fillSetEntries(upTo: reached, at: Date()) doc.logs[i].transition(to: .inProgress) recomputeWorkoutStatus() @@ -589,7 +670,7 @@ struct ExerciseProgressView: View { // MARK: - Formatting - static func durationLabel(_ seconds: Int) -> String { + nonisolated static func durationLabel(_ seconds: Int) -> String { let mins = seconds / 60 let secs = seconds % 60 if mins > 0 && secs > 0 { return "\(mins)m \(secs)s" } @@ -598,6 +679,116 @@ struct ExerciseProgressView: View { } } +// MARK: - Set entry formatting + +/// Renders recorded `SetEntry` actuals for display (the pill, the Completed page). +/// Which fields an entry carries follows the log's `LoadType`, so formatting keys +/// off the populated fields rather than re-deriving the type. +enum SetEntryFormat { + /// One entry: "10 reps · 45 lb", "10 reps", or "45 sec". + static func summary(_ entry: SetEntry, weightUnit: WeightUnit) -> String { + var parts: [String] = [] + if let reps = entry.reps { parts.append("\(reps) reps") } + if let weight = entry.weight { parts.append(weightUnit.format(weight)) } + if let seconds = entry.seconds { parts.append(ExerciseProgressView.durationLabel(seconds)) } + return parts.joined(separator: " · ") + } + + /// All of a log's entries on one line: "10 · 8 · 6 reps @ 45 lb" when the + /// weight is uniform, a per-set "10 × 45 lb · 8 × 42.5 lb" list when it varies, + /// "45 · 45 · 30 sec" for durations, "10 · 8 · 6 reps" for bodyweight. + static func actualsLine(_ entries: [SetEntry], weightUnit: WeightUnit) -> String { + guard !entries.isEmpty else { return "" } + if entries.contains(where: { $0.seconds != nil }) { + return entries.map { "\($0.seconds ?? 0)" }.joined(separator: " · ") + " sec" + } + let repsList = entries.map { "\($0.reps ?? 0)" }.joined(separator: " · ") + let weights = entries.compactMap(\.weight) + guard !weights.isEmpty else { return repsList + " reps" } + if Set(weights).count == 1 { + return "\(repsList) reps @ \(weightUnit.format(weights[0]))" + } + return entries.map { entry in + guard let weight = entry.weight else { return "\(entry.reps ?? 0)" } + return "\(entry.reps ?? 0) × \(weightUnit.format(weight))" + }.joined(separator: " · ") + } +} + +// MARK: - Set entry adjust sheet + +/// Compact steppers for correcting the just-completed set's entry — reps and +/// weight for a weighted set, reps alone for bodyweight, seconds for a timed +/// hold. Changes apply live (via `onChange`), so a swipe-down needs no confirm. +private struct SetEntryAdjustSheet: View { + let setNumber: Int + let loadType: LoadType + let weightUnit: WeightUnit + let onChange: (SetEntry) -> Void + + @State private var entry: SetEntry + + init(entry: SetEntry, setNumber: Int, loadType: LoadType, weightUnit: WeightUnit, + onChange: @escaping (SetEntry) -> Void) { + _entry = State(initialValue: entry) + self.setNumber = setNumber + self.loadType = loadType + self.weightUnit = weightUnit + self.onChange = onChange + } + + /// Plate-math step in the *display* unit (the stored value stays unit-less). + private var weightStep: Double { weightUnit == .kg ? 1.25 : 2.5 } + + var body: some View { + VStack(spacing: 24) { + Text("Set \(setNumber)") + .font(.headline) + .foregroundStyle(.secondary) + + switch loadType { + case .weight: + repsStepper + weightStepper + case .none: + repsStepper + case .duration: + secondsStepper + } + } + .padding(28) + .frame(maxHeight: .infinity, alignment: .top) + .onChange(of: entry) { _, updated in onChange(updated) } + } + + private var repsStepper: some View { + Stepper(value: Binding(get: { entry.reps ?? 0 }, set: { entry.reps = $0 }), + in: 0...200) { + Text("\(entry.reps ?? 0) reps") + .font(.title3.weight(.semibold)) + .monospacedDigit() + } + } + + private var weightStepper: some View { + Stepper(value: Binding(get: { entry.weight ?? 0 }, set: { entry.weight = $0 }), + in: 0...2000, step: weightStep) { + Text(weightUnit.format(entry.weight ?? 0)) + .font(.title3.weight(.semibold)) + .monospacedDigit() + } + } + + private var secondsStepper: some View { + Stepper(value: Binding(get: { entry.seconds ?? 0 }, set: { entry.seconds = $0 }), + in: 0...3600, step: 5) { + Text(ExerciseProgressView.durationLabel(entry.seconds ?? 0)) + .font(.title3.weight(.semibold)) + .monospacedDigit() + } + } +} + // MARK: - Haptics /// Maps the watch flow's haptic vocabulary onto UIKit feedback generators so the iPhone @@ -750,6 +941,8 @@ private extension View { private struct CompletedPhaseView: View { let log: WorkoutLogDocument? + @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb + /// Scales the completion badge with Dynamic Type. @ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96 @@ -785,7 +978,13 @@ private struct CompletedPhaseView: View { if let completedAt = log.completedAt { Text(completedAt.formattedRelativeDateTime()) } - Text(planSummary) + // Recorded actuals ("10 · 8 · 6 reps @ 45 lb") beat the plan + // summary; legacy logs without entries keep showing the plan. + if let entries = log.setEntries, !entries.isEmpty { + Text(SetEntryFormat.actualsLine(entries, weightUnit: weightUnit)) + } else { + Text(planSummary) + } if let elapsed { Text("in \(elapsed)") } diff --git a/Workouts/Views/WorkoutLogs/PlanEditView.swift b/Workouts/Views/WorkoutLogs/PlanEditView.swift index c64f5e7..eb6c203 100644 --- a/Workouts/Views/WorkoutLogs/PlanEditView.swift +++ b/Workouts/Views/WorkoutLogs/PlanEditView.swift @@ -136,7 +136,9 @@ struct PlanEditView: View { if let log = WorkoutDocument(from: workout).logs.first(where: { $0.id == logID }) { sets = log.sets reps = log.reps - weight = log.weight + // The picker steps whole values; a fractional stored weight + // shows (and saves back) its whole part until UX #3's UI lands. + weight = Int(log.weight) durationMinutes = log.durationSeconds / 60 durationSeconds = log.durationSeconds % 60 selectedLoadType = LoadType(rawValue: log.loadType) ?? .weight @@ -153,7 +155,7 @@ struct PlanEditView: View { guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return } doc.logs[i].sets = sets doc.logs[i].reps = reps - doc.logs[i].weight = weight + doc.logs[i].weight = Double(weight) doc.logs[i].durationSeconds = totalSeconds doc.logs[i].loadType = selectedLoadType.rawValue doc.updatedAt = Date() @@ -170,7 +172,7 @@ struct PlanEditView: View { if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) { sDoc.exercises[ei].sets = sets sDoc.exercises[ei].reps = reps - sDoc.exercises[ei].weight = weight + sDoc.exercises[ei].weight = Double(weight) sDoc.exercises[ei].durationSeconds = totalSeconds sDoc.exercises[ei].loadType = selectedLoadType.rawValue sDoc.updatedAt = Date() diff --git a/Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift b/Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift index b19dd3b..72c250d 100644 --- a/Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift +++ b/Workouts/Views/WorkoutLogs/WeightProgressionChartView.swift @@ -34,7 +34,12 @@ struct WeightProgressionChartView: View { @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb private var weightData: [WeightDataPoint] { - logs.map { WeightDataPoint(date: $0.date, weight: $0.weight) } + // Top-set *actual* weight per completed log; legacy logs without recorded + // entries fall back to the planned weight (the old behavior). + logs.map { log in + let topSet = log.setEntries?.compactMap(\.weight).max() + return WeightDataPoint(date: log.date, weight: topSet ?? log.weight) + } } var body: some View { @@ -100,7 +105,7 @@ struct WeightProgressionChartView: View { if weightDifference > 0 { let percentIncrease = firstWeight > 0 - ? Int((Double(weightDifference) / Double(firstWeight)) * 100) + ? Int((weightDifference / firstWeight) * 100) : 0 if percentIncrease >= 20 { return "Amazing progress! You've increased your weight by \(weightUnit.format(weightDifference)) (\(percentIncrease)%)!" @@ -121,5 +126,5 @@ struct WeightProgressionChartView: View { struct WeightDataPoint: Identifiable { let id = UUID() let date: Date - let weight: Int + let weight: Double } diff --git a/WorkoutsTests/DuplicateCleanupPlannerTests.swift b/WorkoutsTests/DuplicateCleanupPlannerTests.swift index 1007324..1fdedf7 100644 --- a/WorkoutsTests/DuplicateCleanupPlannerTests.swift +++ b/WorkoutsTests/DuplicateCleanupPlannerTests.swift @@ -18,7 +18,7 @@ struct DuplicateCleanupPlannerTests { private func exercise( name: String = "Bench Press", order: Int = 0, sets: Int = 3, reps: Int = 10, - weight: Int = 100, loadType: Int = LoadType.weight.rawValue, duration: Int = 0 + weight: Double = 100, loadType: Int = LoadType.weight.rawValue, duration: Int = 0 ) -> ExerciseDocument { ExerciseDocument( id: "EX-\(name)-\(order)", name: name, order: order, sets: sets, reps: reps, weight: weight, diff --git a/WorkoutsTests/SeedReconcilePlannerTests.swift b/WorkoutsTests/SeedReconcilePlannerTests.swift index f89456a..872c2a8 100644 --- a/WorkoutsTests/SeedReconcilePlannerTests.swift +++ b/WorkoutsTests/SeedReconcilePlannerTests.swift @@ -13,7 +13,7 @@ struct SeedReconcilePlannerTests { // MARK: - Fixtures private func exercise( - name: String, order: Int = 0, sets: Int = 3, reps: Int = 10, weight: Int = 100 + name: String, order: Int = 0, sets: Int = 3, reps: Int = 10, weight: Double = 100 ) -> ExerciseDocument { ExerciseDocument( id: "EX-\(name)-\(order)", name: name, order: order, sets: sets, reps: reps, diff --git a/WorkoutsTests/SetEntryTests.swift b/WorkoutsTests/SetEntryTests.swift new file mode 100644 index 0000000..de2b306 --- /dev/null +++ b/WorkoutsTests/SetEntryTests.swift @@ -0,0 +1,215 @@ +import Foundation +import Testing +import IndieSync +@testable import Workouts + +/// Locks the per-set actuals capture invariant on `WorkoutLogDocument` — the +/// `transition(to:)` fill/clear rules every status flip funnels through (list +/// checkbox, run-flow completeExercise/resetExercise, endKeepingProgress on both +/// platforms), the `fillSetEntries` plan-default shapes per `LoadType`, the +/// `effectiveSetEntries` legacy fallback every read path shares, and the +/// actuals-aware `WorkoutVolume`. Also pins that a v3 workout-log file (integer +/// weight, no `setEntries`) still decodes at today's Double/v4 shape. +struct SetEntryTests { + + private static let date = Date(timeIntervalSince1970: 1_700_000_000) + + private func log( + sets: Int = 3, reps: Int = 10, weight: Double = 100, + loadType: LoadType = .weight, durationSeconds: Int = 0, + status: WorkoutStatus = .notStarted, setEntries: [SetEntry]? = nil + ) -> WorkoutLogDocument { + WorkoutLogDocument( + id: "LOG", exerciseName: "Ex", order: 0, sets: sets, reps: reps, weight: weight, + loadType: loadType.rawValue, durationSeconds: durationSeconds, currentStateIndex: 0, + status: status.rawValue, notes: nil, date: Self.date, setEntries: setEntries + ) + } + + // MARK: - transition(to:) fill/clear rules + + /// Completing a log with nothing recorded (the list checkbox path) synthesizes + /// a full plan-default entry set. + @Test func completeFillsAllMissingEntries() { + var l = log(sets: 3, reps: 10, weight: 100) + l.transition(to: .completed) + #expect(l.setEntries?.count == 3) + #expect(l.setEntries?.allSatisfy { $0.reps == 10 && $0.weight == 100 && $0.seconds == nil } == true) + // The fill stamps the completion time on the synthesized entries. + #expect(l.setEntries?.allSatisfy { $0.completedAt == l.completedAt } == true) + } + + /// Completing a log that already recorded (and adjusted) entries fills only the + /// missing tail — the recorded ones are never overwritten. + @Test func completeFillsOnlyMissingTail() { + let recorded = SetEntry(reps: 8, weight: 90, completedAt: Self.date) + var l = log(sets: 3, reps: 10, weight: 100, setEntries: [recorded]) + l.transition(to: .completed) + #expect(l.setEntries?.count == 3) + #expect(l.setEntries?.first == recorded) // preserved, not re-filled + #expect(l.setEntries?.last?.reps == 10) // tail comes from the plan + #expect(l.setEntries?.last?.weight == 100) + } + + /// Re-completing an already-full log changes nothing (idempotent, like the + /// completedAt stamp). + @Test func completeIsIdempotentOnFullEntries() { + var l = log(sets: 2) + l.transition(to: .completed) + let first = l.setEntries + l.transition(to: .completed) + #expect(l.setEntries == first) + } + + /// Reset to notStarted (swiping back to Ready, checkbox un-complete) wipes the + /// recorded entries with the timestamps — the run never happened. + @Test func notStartedClearsEntries() { + var l = log(sets: 2) + l.transition(to: .completed) + l.transition(to: .notStarted) + #expect(l.setEntries == nil) + } + + /// Skipping keeps partial entries — "End Workout, keeping progress" must not + /// discard the sets that were done. + @Test func skippedPreservesPartialEntries() { + let partial = [SetEntry(reps: 10, weight: 100, completedAt: Self.date)] + var l = log(sets: 3, status: .inProgress, setEntries: partial) + l.transition(to: .skipped) + #expect(l.setEntries == partial) + } + + /// Moving to inProgress leaves entries alone (the run flow appends explicitly). + @Test func inProgressLeavesEntriesAlone() { + let partial = [SetEntry(reps: 10, weight: 100, completedAt: Self.date)] + var l = log(sets: 3, status: .inProgress, setEntries: partial) + l.transition(to: .inProgress) + #expect(l.setEntries == partial) + } + + // MARK: - fillSetEntries per LoadType + + @Test func fillShapesWeightedEntries() { + var l = log(reps: 12, weight: 42.5, loadType: .weight) + l.fillSetEntries(upTo: 1, at: Self.date) + #expect(l.setEntries == [SetEntry(reps: 12, weight: 42.5, completedAt: Self.date)]) + } + + @Test func fillShapesBodyweightEntries() { + var l = log(reps: 15, loadType: .none) + l.fillSetEntries(upTo: 1, at: Self.date) + #expect(l.setEntries == [SetEntry(reps: 15, completedAt: Self.date)]) + } + + @Test func fillShapesDurationEntries() { + var l = log(reps: 0, loadType: .duration, durationSeconds: 45) + l.fillSetEntries(upTo: 1, at: Self.date) + #expect(l.setEntries == [SetEntry(seconds: 45, completedAt: Self.date)]) + } + + /// A fill to zero (or below the recorded count) never turns nil into [] and + /// never truncates — entries are append-only. + @Test func fillNeverShrinksOrMaterializesEmpty() { + var untouched = log() + untouched.fillSetEntries(upTo: 0, at: Self.date) + #expect(untouched.setEntries == nil) + + let recorded = [SetEntry(reps: 8, weight: 90, completedAt: Self.date), + SetEntry(reps: 6, weight: 90, completedAt: Self.date)] + var full = log(setEntries: recorded) + full.fillSetEntries(upTo: 1, at: Self.date) + #expect(full.setEntries == recorded) + } + + // MARK: - effectiveSetEntries fallback + + /// Recorded entries win verbatim. + @Test func effectiveEntriesPreferRecorded() { + let recorded = [SetEntry(reps: 8, weight: 90, completedAt: Self.date)] + let l = log(status: .completed, setEntries: recorded) + #expect(l.effectiveSetEntries == recorded) + } + + /// A completed legacy log (no entries on disk) synthesizes plan-default entries. + @Test func effectiveEntriesSynthesizeForCompletedLegacy() { + var l = log(sets: 3, reps: 10, weight: 100, status: .completed) + l.completedAt = Self.date + let entries = l.effectiveSetEntries + #expect(entries.count == 3) + #expect(entries.allSatisfy { $0.reps == 10 && $0.weight == 100 && $0.completedAt == Self.date }) + } + + /// An unfinished log with nothing recorded performed nothing. + @Test func effectiveEntriesEmptyForUnfinishedLegacy() { + #expect(log(status: .notStarted).effectiveSetEntries.isEmpty) + #expect(log(status: .inProgress).effectiveSetEntries.isEmpty) + #expect(log(status: .skipped).effectiveSetEntries.isEmpty) + } + + // MARK: - WorkoutVolume from actuals + + /// Recorded actuals drive the sum: reps×weight per entry, not the plan. + @Test func volumeSumsActualEntries() { + let l = log(sets: 3, reps: 10, weight: 100, status: .completed, setEntries: [ + SetEntry(reps: 10, weight: 100, completedAt: Self.date), // 1000 + SetEntry(reps: 8, weight: 102.5, completedAt: Self.date), // 820 + ]) + #expect(WorkoutVolume.total([l]) == 1820) + } + + /// A completed legacy log (no entries) matches the old sets×reps×weight formula, + /// and mixed actual/legacy logs sum both correctly. + @Test func volumeLegacyEqualsOldFormulaAndMixes() { + let legacyCompleted = log(sets: 4, reps: 10, weight: 135, status: .completed) // 5400 + #expect(WorkoutVolume.total([legacyCompleted]) == 5400) + + // Legacy logs that never completed also keep the old full-plan behavior. + let legacySkipped = log(sets: 3, reps: 8, weight: 100, status: .skipped) // 2400 + #expect(WorkoutVolume.total([legacySkipped]) == 2400) + + let actual = log(sets: 2, reps: 10, weight: 50, status: .completed, setEntries: [ + SetEntry(reps: 10, weight: 50, completedAt: Self.date), // 500 + ]) + #expect(WorkoutVolume.total([legacyCompleted, legacySkipped, actual]) == 8300) + } + + /// Non-weight logs contribute nothing, with or without entries. + @Test func volumeIgnoresUnweightedLogs() { + var timed = log(loadType: .duration, durationSeconds: 45, status: .completed) + timed.transition(to: .completed) + let bodyweight = log(reps: 12, loadType: .none, status: .completed) + #expect(WorkoutVolume.total([timed, bodyweight]) == 0) + } + + // MARK: - Legacy v3 decode + + /// A workout-log shape from a v3 file — integer `weight`, no `setEntries`, no + /// per-log `updatedAt` — decodes at today's shape: the Int lands in the Double + /// natively and both new optionals stay nil. + @Test func decodesLegacyV3WorkoutLog() throws { + let json = """ + { + "id": "01HZZZZZZZZZZZZZZZZZZZZZZW", + "exerciseName": "Bench Press", + "order": 0, + "sets": 4, + "reps": 10, + "weight": 135, + "loadType": 1, + "durationSeconds": 0, + "currentStateIndex": 4, + "status": "completed", + "date": "2023-11-14T22:13:20Z" + } + """ + let decoded = try DocumentCoder.decode(WorkoutLogDocument.self, from: Data(json.utf8)) + #expect(decoded.weight == 135) + #expect(decoded.setEntries == nil) + #expect(decoded.updatedAt == nil) + // And a fractional weight round-trips through the coder. + var fractional = decoded + fractional.weight = 42.5 + let re = try DocumentCoder.decode(WorkoutLogDocument.self, from: DocumentCoder.encode(fractional)) + #expect(re.weight == 42.5) + } +} diff --git a/WorkoutsTests/SplitDocumentCodableTests.swift b/WorkoutsTests/SplitDocumentCodableTests.swift index a633eb3..79c820b 100644 --- a/WorkoutsTests/SplitDocumentCodableTests.swift +++ b/WorkoutsTests/SplitDocumentCodableTests.swift @@ -45,7 +45,7 @@ struct SplitDocumentCodableTests { let decoded = try DocumentCoder.decode(SplitDocument.self, from: data) #expect(decoded == original) - #expect(decoded.schemaVersion == 2) + #expect(decoded.schemaVersion == 3) #expect(decoded.relativePath == "Splits/\(original.id).json") #expect(decoded.exercises.first?.machineSettings?.count == 2) } @@ -100,10 +100,11 @@ struct SplitDocumentCodableTests { /// A v1 split file carrying the since-removed keys (`weightLastUpdated`, /// `weightReminderWeeks`, `category`) must still decode — Codable ignores the - /// extra keys, and every remaining field is present in old files. Re-stamping - /// `schemaVersion` to the current value (what the app does on any rewrite via the - /// cache→document mapper) round-trips cleanly at v2. - @Test func decodesLegacyV1SplitAndRoundTripsAtV2() throws { + /// extra keys, every remaining field is present in old files, and the integer + /// `weight` decodes natively into today's `Double`. Re-stamping `schemaVersion` + /// to the current value (what the app does on any rewrite via the + /// cache→document mapper) round-trips cleanly at the current version. + @Test func decodesLegacyV1SplitAndRoundTripsAtCurrent() throws { let json = """ { "schemaVersion": 1, @@ -143,7 +144,7 @@ struct SplitDocumentCodableTests { let reData = try DocumentCoder.encode(decoded) let reDecoded = try DocumentCoder.decode(SplitDocument.self, from: reData) #expect(reDecoded == decoded) - #expect(reDecoded.schemaVersion == 2) + #expect(reDecoded.schemaVersion == 3) } /// A workout-log file written before `completed` was removed still carries the diff --git a/WorkoutsTests/WorkoutDocumentMapperTests.swift b/WorkoutsTests/WorkoutDocumentMapperTests.swift index f618232..86d6011 100644 --- a/WorkoutsTests/WorkoutDocumentMapperTests.swift +++ b/WorkoutsTests/WorkoutDocumentMapperTests.swift @@ -34,13 +34,16 @@ struct WorkoutDocumentMapperTests { private func log( id: String, name: String, order: Int, status: WorkoutStatus, - machineSettings: [MachineSetting]? = nil, startedAt: Date? = nil, completedAt: Date? = nil + weight: Double = 135, machineSettings: [MachineSetting]? = nil, + startedAt: Date? = nil, completedAt: Date? = nil, + setEntries: [SetEntry]? = nil, updatedAt: Date? = nil ) -> WorkoutLogDocument { WorkoutLogDocument( - id: id, exerciseName: name, order: order, sets: 4, reps: 10, weight: 135, + id: id, exerciseName: name, order: order, sets: 4, reps: 10, weight: weight, loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 2, status: status.rawValue, notes: "note-\(name)", date: Self.logDate, - machineSettings: machineSettings, startedAt: startedAt, completedAt: completedAt + machineSettings: machineSettings, startedAt: startedAt, completedAt: completedAt, + setEntries: setEntries, updatedAt: updatedAt ) } @@ -64,11 +67,18 @@ struct WorkoutDocumentMapperTests { start: Self.start, end: Self.end, status: WorkoutStatus.completed.rawValue, createdAt: Self.created, updatedAt: Self.updated, logs: [ - // order 0: not a machine exercise (nil), never started. + // order 0: not a machine exercise (nil), never started, no entries. log(id: "LOG-A", name: "Bench Press", order: 0, status: .notStarted, machineSettings: nil), - // order 1: machine exercise, nothing recorded yet (empty ≠ nil), completed with timestamps. - log(id: "LOG-B", name: "Leg Press", order: 1, status: .completed, machineSettings: [], - startedAt: Self.startedAt, completedAt: Self.completedAt), + // order 1: machine exercise, nothing recorded yet (empty ≠ nil), completed with + // timestamps, a fractional weight, recorded per-set actuals, and a per-log updatedAt. + log(id: "LOG-B", name: "Leg Press", order: 1, status: .completed, + weight: 42.5, machineSettings: [], + startedAt: Self.startedAt, completedAt: Self.completedAt, + setEntries: [ + SetEntry(reps: 10, weight: 42.5, completedAt: Self.startedAt), + SetEntry(reps: 8, weight: 45, completedAt: Self.completedAt), + ], + updatedAt: Self.updated), // order 2: machine exercise with recorded settings, skipped. log(id: "LOG-C", name: "Chest Press", order: 2, status: .skipped, machineSettings: [MachineSetting(name: "Seat Height", value: "4"), @@ -95,9 +105,17 @@ struct WorkoutDocumentMapperTests { #expect(rebuilt.status == WorkoutStatus.completed.rawValue) #expect(rebuilt.logs.count == 3) #expect(rebuilt.logs[0].machineSettings == nil) // not a machine exercise + #expect(rebuilt.logs[0].setEntries == nil) // nothing recorded stays nil + #expect(rebuilt.logs[0].updatedAt == nil) #expect(rebuilt.logs[1].machineSettings == []) // machine, nothing recorded #expect(rebuilt.logs[1].startedAt == Self.startedAt) #expect(rebuilt.logs[1].completedAt == Self.completedAt) + #expect(rebuilt.logs[1].weight == 42.5) // fractional weight survives + #expect(rebuilt.logs[1].setEntries == [ + SetEntry(reps: 10, weight: 42.5, completedAt: Self.startedAt), + SetEntry(reps: 8, weight: 45, completedAt: Self.completedAt), + ]) + #expect(rebuilt.logs[1].updatedAt == Self.updated) // per-log merge stamp survives #expect(rebuilt.logs[2].machineSettings?.count == 2) #expect(rebuilt.metrics == fullMetrics()) #expect(rebuilt.metrics?.hrZoneSeconds == [10, 20, 30, 40, 50])