Files
workouts/SCHEMA.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

163 lines
8.6 KiB
Markdown
Raw 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.
# SCHEMA.md
The on-disk JSON document schemas — the app's source of truth. The canonical
definitions are the Codable structs in `Shared/Model/Documents.swift` (enums in
`Shared/Model/Enums.swift`); this file is a readable summary. If they disagree,
the code wins.
Encoding (IndieSync `DocumentCoder`): pretty-printed JSON with sorted keys;
dates as ISO-8601 strings. One file per aggregate root:
| Aggregate | Path in iCloud container |
|---|---|
| Split (embeds its exercises) | `Splits/<ULID>.json` |
| Workout (embeds its logs) | `Workouts/YYYY/MM/<ULID>.json` (local-calendar month of `start`) |
| Tombstone (soft-delete stub) | `Stubs/<same relative path as the deleted file>` |
`schemaVersion` gates forward compatibility (`VersionedDocument`): files written
by a newer app version are quarantined, not rewritten. Fields marked *not
schema-bumped* were added as optionals without a version bump — an older app
silently drops them on rewrite, which is accepted as preferable to quarantine.
The same shapes are also the iPhone↔Watch wire format, and each has a mirrored
SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by
`Mappers.swift`.
---
## 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. 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 |
| `systemImage` | String | | SF Symbol name |
| `order` | Int | | Sort position in the splits list |
| `createdAt` | Date | | |
| `updatedAt` | Date | | |
| `exercises` | [ExerciseDocument] | | Embedded aggregate members |
| `activityType` | Int | ✓ | Raw `WorkoutActivityType`; nil → `traditionalStrength`. *Not schema-bumped* |
## ExerciseDocument (embedded in SplitDocument)
| Property | Type | Optional | Notes |
|---|---|---|---|
| `id` | String | | ULID |
| `name` | String | | Also keys the motion rig / picker catalog |
| `order` | Int | | Sort position within the split |
| `sets` | Int | | |
| `reps` | Int | | |
| `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: 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. 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 |
| `start` | Date | | Also determines the file's `YYYY/MM` bucket (never re-bucketed after write) |
| `end` | Date | ✓ | Stamped by `recomputeStatusFromLogs()` when all logs resolve |
| `status` | String | | Raw `WorkoutStatus`; derived from logs, never set directly |
| `createdAt` | Date | | |
| `updatedAt` | Date | | |
| `logs` | [WorkoutLogDocument] | | Embedded aggregate members |
| `metrics` | WorkoutMetrics | ✓ | Nil until the workout completes and a writer fills it in |
## WorkoutLogDocument (embedded in WorkoutDocument)
| Property | Type | Optional | Notes |
|---|---|---|---|
| `id` | String | | ULID |
| `exerciseName` | String | | Snapshot by name — no reference to the exercise's ULID |
| `order` | Int | | Sort position within the workout |
| `sets` | Int | | Planned sets (snapshot from the exercise) |
| `reps` | Int | | Planned reps (snapshot) |
| `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 |
| `status` | String | | Raw `WorkoutStatus`; mutate only via `transition(to:)`. Completion is derived: `completed == (status == "completed")` |
| `notes` | String | ✓ | |
| `date` | Date | | |
| `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)
A single free-form comfort setting for a machine-based exercise. `value` is a
string, not a number, because machine dials aren't uniformly numeric.
| Property | Type | Optional | Notes |
|---|---|---|---|
| `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 |
|---|---|---|---|
| `activeEnergyKcal` | Double | ✓ | |
| `avgHeartRate` | Double | ✓ | bpm; watch sessions only |
| `maxHeartRate` | Double | ✓ | bpm; watch sessions only |
| `minHeartRate` | Double | ✓ | bpm; watch sessions only |
| `totalVolume` | Double | ✓ | Σ sets×reps×weight across weighted logs |
| `hrZoneSeconds` | [Double] | ✓ | Seconds in each of 5 HR zones, low→high |
| `healthKitWorkoutUUID` | String | ✓ | Presence is the phone/watch Health-write dedupe signal |
| `source` | String | | Raw `MetricSource` |
| `recordedAt` | Date | | |
## Tombstone (IndieSync, minimal stub)
A delete writes a stub to `Stubs/…`, then removes the live file. A **full stub**
is a verbatim copy of the record's JSON plus an injected `deletedAt`; the
**minimal stub** below is the fallback when the source is unreadable. Stubs are
pruned after a 30-day grace period, except starter-seed stubs (exempt, and they
veto seed resurrection forever).
| Property | Type | Optional | Notes |
|---|---|---|---|
| `id` | String | | The deleted record's ULID |
| `deletedAt` | Date | | |
| `kind` | String | ✓ | App-defined category |
---
## Enum raw values
**WorkoutStatus** (String — on `WorkoutDocument.status`, `WorkoutLogDocument.status`):
`notStarted`, `inProgress`, `completed`, `skipped`
**LoadType** (Int — on `ExerciseDocument.loadType`, `WorkoutLogDocument.loadType`):
`0` none · `1` weight · `2` duration
**WorkoutActivityType** (Int — on `SplitDocument.activityType`):
`0` traditionalStrength · `1` functionalStrength · `2` hiit · `3` coreTraining · `4` cardio · `5` cycling
**MetricSource** (String — on `WorkoutMetrics.source`):
`watch`, `phoneEstimate`
**WeightUnit** (`lb`/`kg`) is a display setting only — it is never persisted in
documents, and stored weight values are never rewritten when it changes.