Files
workouts/PLAN-UX1-per-set-actuals.md
rzen 394ec0989e 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
2026-07-08 12:48:37 -04:00

204 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 109119 and 167178).
### `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 480545).
`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 284290) 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:3638`: 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.