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
204 lines
11 KiB
Markdown
204 lines
11 KiB
Markdown
# 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.
|