Files
workouts/PLAN-UX1-per-set-actuals.md
T
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

11 KiB
Raw Blame History

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):
    /// 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: IntDouble. 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.
  • .notStartedsetEntries = 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: IntDouble (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.