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
This commit is contained in:
2026-07-06 16:29:44 -04:00
parent fce8fa4c17
commit 2c1e4759ae
33 changed files with 1132 additions and 586 deletions
+80 -11
View File
@@ -30,7 +30,12 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
/// quarantining the user's whole routine.
var activityType: Int?
static let currentSchemaVersion = 1
// Bumped 12 when the weight-reminder fields (`weightLastUpdated`,
// `weightReminderWeeks`) and `category` were removed and `machineSettings` was
// added. Removing required fields means older apps can no longer decode new
// files, so the forward-gate must quarantine them rather than let an older app
// silently rewrite them.
static let currentSchemaVersion = 2
var relativePath: String { "Splits/\(id).json" }
}
@@ -44,12 +49,19 @@ struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable {
var weight: Int
var loadType: Int
var durationSeconds: Int // total seconds (0 when not a timed exercise)
var weightLastUpdated: Date?
var weightReminderWeeks: Int
/// Raw `ExerciseCategory`; nil main circuit. Like `activityType`, deliberately
/// NOT schema-bumped: it only affects how the split screen groups its list, so an
/// older app dropping it on rewrite beats quarantining the routine.
var category: Int?
/// Ordered, user-defined comfort settings for machine-based exercises (seat
/// height, back rest incline, pin position). `nil` not a machine exercise;
/// empty a machine exercise with nothing recorded yet. This optionality
/// doubles as the machine-based flag there is no separate Bool.
var machineSettings: [MachineSetting]? = nil
}
/// 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 ("3rd
/// hole", "45°"). Ordered within `ExerciseDocument.machineSettings`.
struct MachineSetting: Codable, Sendable, Equatable {
var name: String // "Seat Height", "Back Rest Incline"
var value: String // "4", "3rd hole", "45°" free-form; machine dials aren't uniformly numeric
}
// MARK: - Workout
@@ -74,7 +86,11 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
// Bumped 12 when `metrics` was added: the captured HR/calorie data is
// irreplaceable, so the forward-gate must quarantine these files on older apps
// rather than let them strip `metrics` on rewrite.
static let currentSchemaVersion = 2
// Bumped 23 when the derived `completed` flag was removed from
// `WorkoutLogDocument` and `machineSettings` was added to it. Dropping a
// required field means older apps can no longer decode new files, so the
// forward-gate must quarantine them.
static let currentSchemaVersion = 3
var relativePath: String { Self.relativePath(id: id, start: start) }
@@ -119,8 +135,7 @@ extension WorkoutDocument {
/// start-a-new-split prompt.
mutating func endKeepingProgress() {
for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed {
logs[i].status = WorkoutStatus.skipped.rawValue
logs[i].completed = false
logs[i].transition(to: .skipped)
}
recomputeStatusFromLogs()
updatedAt = Date()
@@ -137,10 +152,64 @@ struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
var loadType: Int
var durationSeconds: Int // total seconds (0 when not a timed exercise)
var currentStateIndex: Int
var completed: Bool
var status: String // WorkoutStatus raw value
var notes: String?
var date: Date
/// Snapshot of the exercise's machine comfort settings at plan time (like the
/// sets/reps/weight snapshot). `nil` not a machine exercise; empty a machine
/// exercise with nothing recorded yet.
var machineSettings: [MachineSetting]? = nil
/// When the log first moved to `.inProgress` / last moved to `.completed`.
/// Optional additions absent in files written before they existed.
var startedAt: Date? = nil
var completedAt: Date? = nil
}
extension WorkoutLogDocument {
/// Build a fresh plan-time log from a split's exercise. Snapshots the plan
/// fields (sets/reps/weight/duration) *and* the machine comfort settings, so a
/// running workout carries the exercise's settings as its own editable copy
/// `nil` stays `nil` (not a machine exercise), and `[]` stays `[]` (machine
/// exercise, nothing recorded yet). Every "start a workout" / "add exercise to
/// a workout" path funnels through here so the snapshot can't drift per site.
init(planFrom exercise: ExerciseDocument, order: Int, date: Date) {
self.init(
id: ULID.make(),
exerciseName: exercise.name,
order: order,
sets: exercise.sets,
reps: exercise.reps,
weight: exercise.weight,
loadType: exercise.loadType,
durationSeconds: exercise.durationSeconds,
currentStateIndex: 0,
status: WorkoutStatus.notStarted.rawValue,
notes: nil,
date: date,
machineSettings: exercise.machineSettings
)
}
/// Move to a new status, keeping the started/completed timestamps consistent.
/// Every screen that flips a log's status (phone and watch) goes through here,
/// so the timestamps can't drift per-call-site. Completion is derived from
/// `status` (`completed == (status == WorkoutStatus.completed.rawValue)`), so
/// there's no separate flag to keep in sync.
mutating func transition(to newStatus: WorkoutStatus) {
status = newStatus.rawValue
switch newStatus {
case .notStarted:
startedAt = nil
completedAt = nil
case .inProgress:
if startedAt == nil { startedAt = Date() }
completedAt = nil
case .completed:
if completedAt == nil { completedAt = Date() }
case .skipped:
completedAt = nil
}
}
}
// MARK: - Workout health metrics