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

11 KiB
Raw Permalink Blame History

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.