import Foundation import IndieSync /// On-disk JSON shape for each aggregate. Independent from the SwiftData cache /// entities so the wire format can evolve without dragging the cache schema. /// /// One document = one aggregate root: /// • `SplitDocument` embeds its `[ExerciseDocument]` → `Splits/.json` /// • `WorkoutDocument` embeds its `[WorkoutLogDocument]` → `Workouts/YYYY/MM/.json` /// /// `schemaVersion` lets us migrate old files on read without forcing a rewrite /// at sync time, and forward-gates files written by a newer app version. /// These documents are also the wire format for the iPhone↔Watch bridge. // MARK: - Split struct SplitDocument: Codable, Sendable, Equatable, Identifiable { var schemaVersion: Int var id: String // ULID var name: String var color: String var systemImage: String var order: Int var createdAt: Date var updatedAt: Date var exercises: [ExerciseDocument] /// Raw `WorkoutActivityType`; nil → traditional strength training. Optional and /// deliberately NOT schema-bumped: it's a minor categorization preference, so an /// older app dropping it on rewrite (reverting to the default) is preferable to /// quarantining the user's whole routine. var activityType: Int? // 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" } } struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable { var id: String // ULID var name: String var order: Int var sets: Int var reps: Int var weight: Int var loadType: Int var durationSeconds: Int // total seconds (0 when not a timed exercise) /// 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 struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable { var schemaVersion: Int var id: String // ULID (chronological) var splitID: String? var splitName: String? var start: Date var end: Date? var status: String // WorkoutStatus raw value var createdAt: Date var updatedAt: Date var logs: [WorkoutLogDocument] /// Health metrics captured for a finished workout (watch sensors) or estimated /// by the phone. Nil until the workout completes and a writer fills it in. The /// presence of `metrics.healthKitWorkoutUUID` is the dedupe signal that keeps the /// watch and the phone from both writing the same workout to Health. var metrics: WorkoutMetrics? // 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. // 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) } static func relativePath(id: String, start: Date) -> String { let cal = Calendar(identifier: .gregorian) let comps = cal.dateComponents([.year, .month], from: start) let year = comps.year ?? 1970 let month = comps.month ?? 1 return String(format: "Workouts/%04d/%02d/%@.json", year, month, id) } } extension WorkoutDocument { /// Derive the aggregate `status` + `end` from the current logs. A log that is /// `.completed` or `.skipped` counts as *resolved*; a workout whose logs are all /// resolved is finished (`.completed`, with `end` stamped). This is the single /// source of the status-from-logs rule — every screen that mutates logs calls it, /// so an ended workout (remaining exercises skipped) stays finished no matter which /// screen the next edit comes from. mutating func recomputeStatusFromLogs() { let statuses: [WorkoutStatus] = logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted } let isResolved: (WorkoutStatus) -> Bool = { $0 == .completed || $0 == .skipped } let allResolved = !statuses.isEmpty && statuses.allSatisfy(isResolved) let anyStarted = statuses.contains { $0 != .notStarted } if allResolved { status = WorkoutStatus.completed.rawValue end = Date() } else if anyStarted { status = WorkoutStatus.inProgress.rawValue end = nil } else { status = WorkoutStatus.notStarted.rawValue end = nil } } /// End the workout now, keeping progress: mark every not-completed log as skipped, then /// recompute so it resolves to `.completed` (with `end` stamped). This is the /// "End Workout → Save" operation, shared by the in-workout menu and the /// start-a-new-split prompt. mutating func endKeepingProgress() { for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed { logs[i].transition(to: .skipped) } recomputeStatusFromLogs() updatedAt = Date() } } struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable { var id: String // ULID var exerciseName: String var order: Int var sets: Int var reps: Int var weight: Int var loadType: Int var durationSeconds: Int // total seconds (0 when not a timed exercise) var currentStateIndex: Int 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 /// Health metrics for one finished workout. Embedded in `WorkoutDocument` so they /// ride the existing iCloud + Watch-bridge paths with the rest of the workout. /// Every field except `source`/`recordedAt` is optional — a phone estimate carries /// only calories + volume, while a watch session fills in heart rate too. struct WorkoutMetrics: Codable, Sendable, Equatable { var activeEnergyKcal: Double? var avgHeartRate: Double? // bpm var maxHeartRate: Double? // bpm var minHeartRate: Double? // bpm var totalVolume: Double? // Σ sets×reps×weight across weighted logs var hrZoneSeconds: [Double]? // seconds spent in each of 5 HR zones (low→high) var healthKitWorkoutUUID: String? var source: MetricSource var recordedAt: Date } // MARK: - Forward-compatibility gate // `VersionedDocument` (the `isReadable` quarantine gate for files written by a // newer app version), `Tombstone`, and `DocumentCoder` all live in IndieSync now. extension SplitDocument: VersionedDocument {} extension WorkoutDocument: VersionedDocument {}