import Foundation /// 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] static let currentSchema = 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 } // 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] static let currentSchema = 1 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) } } 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: - Forward-compatibility gate /// A file whose `schemaVersion` exceeds the reader's `currentSchema` was written /// by a newer app version; it must be quarantined, not partially decoded (Codable /// silently drops unknown keys) and later rewritten — which would downgrade it /// and lose the newer fields. protocol VersionedDocument { var schemaVersion: Int { get } static var currentSchema: Int { get } } extension SplitDocument: VersionedDocument {} extension WorkoutDocument: VersionedDocument {} extension VersionedDocument { /// True if this build can safely read (and rewrite) the document. var isReadable: Bool { schemaVersion <= Self.currentSchema } } // MARK: - Soft-delete tombstone /// Lives at `Stubs/.json` and tells every device "this aggregate has been /// deleted" — important when a remote device missed the `.removed` event for the /// live file because it was offline. After `gracePeriod` any device can prune it. struct Tombstone: Codable, Sendable, Equatable { enum Kind: String, Codable, Sendable { case split case workout } var id: String // the aggregate's ULID var kind: Kind var deletedAt: Date var relativePath: String { "Stubs/\(id).json" } static let gracePeriod: TimeInterval = 30 * 24 * 60 * 60 } // MARK: - JSON coding /// Shared encoder/decoder. ISO-8601 dates and sorted keys for human-readable, /// diff-friendly files when the container is browsed via the Files app. enum DocumentCoder { static let encoder: JSONEncoder = { let e = JSONEncoder() e.outputFormatting = [.prettyPrinted, .sortedKeys] e.dateEncodingStrategy = .iso8601 return e }() static let decoder: JSONDecoder = { let d = JSONDecoder() d.dateDecodingStrategy = .iso8601 return d }() }