Files
rzen df5c77eee1 Let routines rename exercises and repeat them for interval segments
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
2026-07-12 18:38:14 -04:00

172 lines
11 KiB
Markdown
Raw Permalink 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* |
| `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.