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
11 KiB
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]onWorkoutLogDocument. SetEntryweights areDouble, and the plan-sideweightfields flip Int →Doublein 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?onWorkoutLogDocumentnow (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
SetEntrypre-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):/// 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: addvar setEntries: [SetEntry]? = nil(nil = legacy file / nothing recorded) andvar updatedAt: Date? = nil(reserved for H1 per-log merge — document that in a comment; nothing writes it yet).WorkoutLogDocument.weightandExerciseDocument.weight:Int→Double. JSON decode of existing integer weights intoDoubleis native — no read migration.- Schema bumps (both documents — an older app decoding
45.5intoIntfails, and an older app rewriting a file would stripsetEntries; actuals are irreplaceable, same rationale as the v2metricsbump):SplitDocument.currentSchemaVersion2 → 3WorkoutDocument.currentSchemaVersion3 → 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→ fillsetEntriesup tosetswith plan-derived entries (only the missing tail; never overwrite recorded ones). Covers the phone list checkbox, the watch checkbox, andcompleteExercisein both run flows with one implementation..notStarted→setEntries = nil(coversresetExerciseand 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 perLoadType(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: addvar setEntries: [SetEntry]?andvar logUpdatedAt: Date?(updatedAtmay 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
currentSchemaVersion4 → 5 (wipes + rebuilds the cache from files).
SCHEMA.md
- Update both document tables (new versions + rationale in the
schemaVersionnotes row,weighttype change,setEntries, per-logupdatedAt), add aSetEntrytable. Memory/repo rule: SCHEMA.md must track code.
Starter seeds
weightis nowDouble; regenerate seeds withScripts/generate_starter_splits.swift(script'sschemaVersionoutput bumps to 3; integer literals still compile intoDoubleparams). Check:DocumentCoder/JSONSerialization should still encode integral doubles as45, not45.0— if bytes change beyondschemaVersion, that's fine (reconcile's semantic compare + fixed ULIDs handle the upgrade), just confirm the new bytes are deterministic across runs.Shared/Screenshots/ScreenshotSeed.swiftcompiles as-is (Int literals → Double), but give completed logs plausiblesetEntriesso marketing screenshots show actuals.
Weight formatting
WeightUnit.format(_ value: Int)(Enums.swift:105) → add/replace with aDoubleoverload that trims a trailing.0("45 lb", "42.5 kg"). Fix call sites (Exercise.planSummary, summary views, watch views — grepweightUnit.formatand.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): whencurrentStateIndexadvances 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 viacurrentStateIndex— 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 lastSetEntry, stampsdoc.updatedAt, callsonChange(). 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/addSetentry 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,
LiveProgresscarries 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 whattransition(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 (setEntriesmax weight, fallback plannedweight). Keep the query as-is (entity now carriessetEntries). - Volume
Shared/HealthKit/HealthKitMapping.swift:33WorkoutVolume.total: Σ reps×weight over effective entries for weighted logs (fallback = old sets×reps×weight formula, unchanged result for legacy). Callers —WorkoutSummaryView.swift:82and watchActiveWorkoutGateView.swift:120(metrics.totalVolumeat 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-tripsetEntries, per-logupdatedAt, Double weight (fractional value survives doc→entity→doc).- New
SetEntryTests(or fold intoWorkoutStatusMachineTests):transition(to:)fill-on-complete (only missing tail), clear-on-notStarted, skip preserves partials;fillSetEntriesper LoadType;effectiveSetEntriesfallback 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 (checkSeedLibraryTests/ IndieSync gate tests). - Watch target:
WatchCacheApplierround trip includes the new fields. WorkoutPathBucketingTestsetc. 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 generatenot needed unless targets/files change inproject.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-changelogskill), e.g. recording what you actually lifted per set and the progress chart now tracking it. SCHEMA.md updated.