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? static let currentSchemaVersion = 1 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) 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? } // 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. static let currentSchemaVersion = 2 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].status = WorkoutStatus.skipped.rawValue logs[i].completed = false } 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 completed: Bool var status: String // WorkoutStatus raw value var notes: String? var date: Date } // 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 {}