Record per-set actuals and drive the chart and volume from them
Every completed set now writes a SetEntry (reps/weight or seconds), pre-filled from the plan by transition(to:) so the list checkbox, both run flows, and One More all capture for free; reset clears, skip keeps partials. The rest and finish pages show the just-done set as a pill that opens a stepper sheet for correcting reps and weight (2.5 lb / 1.25 kg steps). The Weight Progression chart plots the top-set actual weight and workout volume sums recorded sets, both falling back to the plan for legacy logs via effectiveSetEntries. Storage side of UX #3 rides along: plan weights are Double now. Schema bumps: SplitDocument 2→3, WorkoutDocument 3→4 (a fractional weight fails an older Int decode, and a rewrite would strip the irreplaceable actuals), SwiftData cache 4→5. A per-log updatedAt is reserved for the future cross-device log merge. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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..<max(0, sets)).map { _ in planSetEntry(completedAt: date) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout health metrics
|
||||
|
||||
@@ -59,7 +59,7 @@ final class Exercise {
|
||||
var order: Int = 0
|
||||
var sets: Int = 0
|
||||
var reps: Int = 0
|
||||
var weight: Int = 0
|
||||
var weight: Double = 0
|
||||
var loadType: Int = LoadType.weight.rawValue
|
||||
var durationTotalSeconds: Int = 0
|
||||
// Machine comfort settings — stored directly as an optional Codable array
|
||||
@@ -70,7 +70,7 @@ final class Exercise {
|
||||
|
||||
var split: Split?
|
||||
|
||||
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int,
|
||||
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Double,
|
||||
loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
@@ -223,7 +223,7 @@ final class WorkoutLog {
|
||||
var order: Int = 0
|
||||
var sets: Int = 0
|
||||
var reps: Int = 0
|
||||
var weight: Int = 0
|
||||
var weight: Double = 0
|
||||
var loadType: Int = LoadType.weight.rawValue
|
||||
var durationTotalSeconds: Int = 0
|
||||
var currentStateIndex: Int = 0
|
||||
@@ -235,14 +235,21 @@ final class WorkoutLog {
|
||||
var machineSettings: [MachineSetting]?
|
||||
var startedAt: Date?
|
||||
var completedAt: Date?
|
||||
// Per-set actuals — stored directly as an optional Codable array, same
|
||||
// pattern as `machineSettings`. `nil` → legacy file / nothing recorded.
|
||||
var setEntries: [SetEntry]?
|
||||
// Mirrors the document's per-log `updatedAt` (named to dodge a future
|
||||
// SwiftData column collision). Reserved for H1's per-log merge; unused.
|
||||
var logUpdatedAt: Date?
|
||||
|
||||
var workout: Workout?
|
||||
|
||||
init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int,
|
||||
init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Double,
|
||||
loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int,
|
||||
statusRaw: String, notes: String?, date: Date,
|
||||
machineSettings: [MachineSetting]? = nil,
|
||||
startedAt: Date? = nil, completedAt: Date? = nil) {
|
||||
startedAt: Date? = nil, completedAt: Date? = nil,
|
||||
setEntries: [SetEntry]? = nil, logUpdatedAt: Date? = nil) {
|
||||
self.id = id
|
||||
self.exerciseName = exerciseName
|
||||
self.order = order
|
||||
@@ -258,6 +265,8 @@ final class WorkoutLog {
|
||||
self.machineSettings = machineSettings
|
||||
self.startedAt = startedAt
|
||||
self.completedAt = completedAt
|
||||
self.setEntries = setEntries
|
||||
self.logUpdatedAt = logUpdatedAt
|
||||
}
|
||||
|
||||
var status: WorkoutStatus {
|
||||
|
||||
@@ -81,8 +81,8 @@ enum MetricSource: String, Codable, Sendable {
|
||||
case phoneEstimate
|
||||
}
|
||||
|
||||
/// Display unit for weights. The stored weight values are plain integers and are
|
||||
/// never rewritten when this changes — switching only relabels what's shown.
|
||||
/// Display unit for weights. The stored weight values are unit-less numbers and
|
||||
/// are never rewritten when this changes — switching only relabels what's shown.
|
||||
enum WeightUnit: String, CaseIterable, Codable, Sendable {
|
||||
case lb
|
||||
case kg
|
||||
@@ -101,6 +101,15 @@ enum WeightUnit: String, CaseIterable, Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a stored weight value with the current unit's label, trimming a
|
||||
/// trailing ".0" so whole values stay clean: "45 lb", "42.5 kg".
|
||||
func format(_ value: Double) -> 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)" }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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..<sets).map { i in
|
||||
SetEntry(reps: i == sets - 1 ? max(1, reps - 2) : reps, weight: weight,
|
||||
completedAt: date.addingTimeInterval(Double(i) * 150))
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -491,6 +491,9 @@ struct ExerciseProgressView: View {
|
||||
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
|
||||
doc.logs[i].sets = newCount
|
||||
doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete
|
||||
// The old final set just became a completed *prior* set — record its entry.
|
||||
// The bonus set's own entry lands when it completes, not now.
|
||||
doc.logs[i].fillSetEntries(upTo: newCount - 1, at: Date())
|
||||
doc.logs[i].transition(to: .inProgress)
|
||||
recomputeWorkoutStatus()
|
||||
doc.updatedAt = Date()
|
||||
@@ -518,6 +521,9 @@ struct ExerciseProgressView: View {
|
||||
guard reached > 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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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..<totalPages, id: \.self) { index in
|
||||
@@ -443,12 +460,15 @@ struct ExerciseProgressView: View {
|
||||
let cycleIndex = index - base
|
||||
if cycleIndex == cycleCount {
|
||||
// Finish page — confirm Done (auto-fires) or add One More.
|
||||
VStack(spacing: 0) {
|
||||
FinishPhaseView(
|
||||
isActive: isActive,
|
||||
onDone: { completeExercise(); dismiss() },
|
||||
onOneMore: addSet,
|
||||
anchorEnd: anchorEnd
|
||||
)
|
||||
adjustPill
|
||||
}
|
||||
} else if cycleIndex.isMultiple(of: 2) {
|
||||
let setNumber = cycleIndex / 2 + 1
|
||||
if isDuration {
|
||||
@@ -476,6 +496,7 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
} else {
|
||||
// Rest phase. Auto-advances to the next work page when the timer hits zero.
|
||||
VStack(spacing: 0) {
|
||||
CountdownPhaseView(
|
||||
header: "Rest",
|
||||
tint: .restTimer,
|
||||
@@ -486,9 +507,63 @@ struct ExerciseProgressView: View {
|
||||
) {
|
||||
withAnimation { advance(from: index) }
|
||||
}
|
||||
adjustPill
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Adjust the last set's entry
|
||||
|
||||
/// The just-completed set's recorded entry, shown on the rest and finish pages
|
||||
/// as a tappable pill — the log-by-default record with a correct-by-exception
|
||||
/// affordance. Dead time by design: it never blocks the countdown or auto-advance.
|
||||
@ViewBuilder
|
||||
private var adjustPill: some View {
|
||||
if let entry = log?.setEntries?.last {
|
||||
Button {
|
||||
showingAdjust = true
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(SetEntryFormat.summary(entry, weightUnit: weightUnit))
|
||||
Image(systemName: "pencil")
|
||||
.font(.caption)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(.quaternary.opacity(0.5), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.bottom, 32) // clear the phase-dot row overlaid at the bottom
|
||||
.sheet(isPresented: $showingAdjust) { adjustSheet }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var adjustSheet: some View {
|
||||
if let log, let entry = log.setEntries?.last {
|
||||
SetEntryAdjustSheet(
|
||||
entry: entry,
|
||||
setNumber: log.setEntries?.count ?? 1,
|
||||
loadType: LoadType(rawValue: log.loadType) ?? .weight,
|
||||
weightUnit: weightUnit,
|
||||
onChange: updateLastEntry
|
||||
)
|
||||
.presentationDetents([.height(300)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an adjusted value back onto the last recorded entry and persist.
|
||||
private func updateLastEntry(_ entry: SetEntry) {
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }),
|
||||
let last = doc.logs[i].setEntries?.indices.last else { return }
|
||||
doc.logs[i].setEntries?[last] = entry
|
||||
doc.updatedAt = Date()
|
||||
onChange()
|
||||
}
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
@@ -525,6 +600,9 @@ struct ExerciseProgressView: View {
|
||||
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
|
||||
doc.logs[i].sets = newCount
|
||||
doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete
|
||||
// The old final set just became a completed *prior* set — record its entry.
|
||||
// The bonus set's own entry lands when it completes, not now.
|
||||
doc.logs[i].fillSetEntries(upTo: newCount - 1, at: Date())
|
||||
doc.logs[i].transition(to: .inProgress)
|
||||
recomputeWorkoutStatus()
|
||||
doc.updatedAt = Date()
|
||||
@@ -552,6 +630,9 @@ struct ExerciseProgressView: View {
|
||||
guard reached > 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())
|
||||
}
|
||||
// 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)")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user