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:
@@ -30,7 +30,12 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
/// quarantining the user's whole routine.
|
||||
var activityType: Int?
|
||||
|
||||
static let currentSchemaVersion = 1
|
||||
// Bumped 1→2 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 1→2 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 2→3 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
|
||||
|
||||
Reference in New Issue
Block a user