Every completed set now writes a SetEntry (reps/weight or seconds), pre-filled from the plan by transition(to:) so the list checkbox, both run flows, and One More all capture for free; reset clears, skip keeps partials. The rest and finish pages show the just-done set as a pill that opens a stepper sheet for correcting reps and weight (2.5 lb / 1.25 kg steps). The Weight Progression chart plots the top-set actual weight and workout volume sums recorded sets, both falling back to the plan for legacy logs via effectiveSetEntries. Storage side of UX #3 rides along: plan weights are Double now. Schema bumps: SplitDocument 2→3, WorkoutDocument 3→4 (a fractional weight fails an older Int decode, and a rewrite would strip the irreplaceable actuals), SwiftData cache 4→5. A per-log updatedAt is reserved for the future cross-device log merge. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
8.6 KiB
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 |
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 |
WorkoutDocument — currentSchemaVersion: 4
| 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 |
|
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 |
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 | ✓ | Reserved for the future per-log merge (H1); nothing writes it yet. Added in schema v4 |
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
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.