An exercise's name is now a per-routine display name: a new optional libraryName on ExerciseDocument (snapshotted onto WorkoutLogDocument at plan time) keeps the link to the bundled library exercise, and every figure/info/cue lookup resolves libraryName ?? name. Deliberately not schema-bumped — an older app dropping the key only strands the figure link, same rationale as activityType. Cache schema bumped to 9 for the new columns. The picker no longer filters out exercises already in the routine (an "×N" badge marks them instead), exercise rows gain a leading Duplicate swipe that clones an entry in place, and the edit sheet gets a Name field with the library exercise shown read-only above it. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
172 lines
11 KiB
Markdown
172 lines
11 KiB
Markdown
# 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* |
|
||
| `restSeconds` | Int | ✓ | Per-routine rest length; nil → the Settings default applies. *Not schema-bumped* |
|
||
| `autoAdvance` | Bool | ✓ | Flow mode: finishing an exercise rests, then auto-advances into the next; nil → off. *Not schema-bumped* |
|
||
| `targetHeartRate` | Int | ✓ | Target bpm for endurance routines (`WorkoutActivityType.supportsHeartRateTarget`) — drives the live too-low/too-high indicator during a run; nil → no target. *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 |
|
||
| `libraryName` | String | ✓ | The library exercise `name` was customized from (e.g. one Treadmill segment renamed "Brisk Walk 10 min"); nil → `name` IS the library name. Figure/info/cue lookups resolve `libraryName ?? name`, never `name` directly. *Not schema-bumped* |
|
||
|
||
## WorkoutDocument — `currentSchemaVersion: 5`
|
||
|
||
| 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. Bumped 4→5 when per-log `updatedAt` began being written and `deletedLogIDs` was added for the per-log merge — a rewrite by an older app would strip the deletion tombstones and resurrect a deleted exercise, so older apps 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 |
|
||
| `deletedLogIDs` | [String: Date] | ✓ | Per-log deletion tombstones (`logID → when deleted`), phone-authored. Disambiguate an absent log during the per-log merge so a stale watch push can't resurrect a removed exercise; pruned after a 30-day grace. Added in schema v5 (`WorkoutMergePlanner`) |
|
||
| `restSeconds` | Int | ✓ | Plan-time snapshot of the routine's rest length (a running workout has no live link to its routine); nil → the Settings default. *Not schema-bumped* |
|
||
| `autoAdvance` | Bool | ✓ | Plan-time snapshot of the routine's flow-mode flag; nil → off. *Not schema-bumped* |
|
||
| `targetHeartRate` | Int | ✓ | Plan-time snapshot of the routine's target bpm; nil → no target. *Not schema-bumped* |
|
||
|
||
## 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 | ✓ | Per-log modification time driving the per-log merge (`WorkoutMergePlanner`); stamped by `transition(to:)` on status flips and `touch()` at content-only edit sites. Nil in files written before v5. Added (unused) in schema v4, written since v5 |
|
||
| `libraryName` | String | ✓ | Plan-time snapshot of the exercise's `libraryName` (like sets/reps/weight); nil → `exerciseName` IS the library name. *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°" |
|
||
|
||
## 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 · `6` warmUp · `7` stretching · `8` mindAndBody
|
||
|
||
**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.
|