Add machine comfort settings and tighten the document schemas
Machine-based exercises now carry ordered name/value comfort settings (seat height, back-rest position, ...) on both ExerciseDocument and, as a plan-time snapshot, WorkoutLogDocument — the optional array doubles as the machine flag. Editable from the exercise editor's new Machine section and from the workout row's settings sheet, whose mid-workout edits write back to the originating split (workout saved first, so seed clone-on-edit repointing can't clobber the log edit). Schema tightening rides the same rev: splits bump to v2 (weight-reminder fields and the unused exercise category removed), workouts to v3 (the derived `completed` flag removed; status is the single source). Starter seeds regenerated at v2 with unchanged ULIDs; SwiftData cache schema bumped to rebuild. SCHEMA.md documents the shapes. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
# 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: 2`
|
||||
|
||||
| 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 |
|
||||
| `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` | Int | | Unit-less integer; `WeightUnit` only relabels display |
|
||||
| `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`
|
||||
|
||||
| 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 |
|
||||
| `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` | Int | | Snapshot; unit-less integer |
|
||||
| `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* |
|
||||
|
||||
## 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°" |
|
||||
|
||||
## 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 integers are never rewritten when it changes.
|
||||
Reference in New Issue
Block a user