Files
workouts/SCHEMA.md
T
rzen 2c1e4759ae 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
2026-07-06 16:29:44 -04:00

149 lines
7.2 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: 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.