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:
2026-07-08 12:48:37 -04:00
parent c05e83cff7
commit 394ec0989e
24 changed files with 1193 additions and 77 deletions
+21 -7
View File
@@ -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.