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) } } 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: - 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 }() }