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: /// • `RoutineDocument` 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: - Routine struct RoutineDocument: 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? /// Per-routine rest length (seconds) and flow-mode toggle. Both optional and /// deliberately NOT schema-bumped, same rationale as `activityType`: an older /// app dropping them on rewrite reverts to the global default rest / no-flow, /// which is preferable to quarantining the user's whole routine. var restSeconds: Int? = nil var autoAdvance: Bool? = nil // 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. // Bumped 2→3 when `ExerciseDocument.weight` went Int → Double: an older app // decoding a fractional weight (e.g. 45.5) into `Int` fails, so it must // quarantine rather than rewrite. static let currentSchemaVersion = 3 // PINNED: the iCloud directory name stays "Splits/" even though the type is now // `RoutineDocument`. Existing user files live under Splits/ — the directory name // is on-disk data, not a symbol, and must not change on the rename. 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: Double // unit-less; fractional since schema v3 (Int weights decode natively) 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 } /// One performed set. Which fields are set follows the log's `LoadType`: /// weight → reps + weight; none (bodyweight) → reps; duration → seconds. struct SetEntry: Codable, Sendable, Equatable { var reps: Int? = nil var weight: Double? = nil var seconds: Int? = nil var completedAt: Date } // MARK: - Workout struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable { var schemaVersion: Int var id: String // ULID (chronological) var routineID: String? var routineName: 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? /// Per-log deletion tombstones — `logID → when deleted`. Authored only by the phone /// (`WorkoutLogListView.deleteLog`, the sole log-removal site). They disambiguate an /// absent log during the per-log merge: without them, a log the watch still carries /// (having missed a phone-side delete) is indistinguishable from one the watch just /// added. `nil`/absent in files written before the merge shipped; pruned after a grace /// period. See `WorkoutMergePlanner`. var deletedLogIDs: [String: Date]? = nil /// Snapshot of the routine's rest length / flow flag at plan time (a running /// workout has no live link to its routine, same reason sets/reps/weight are /// snapshotted per log). Optional and not schema-bumped, same rationale as /// `RoutineDocument.restSeconds`/`autoAdvance`. var restSeconds: Int? = nil var autoAdvance: Bool? = nil // PINNED CODING KEYS: the Swift properties `routineID`/`routineName` were renamed // from `splitID`/`splitName`, but their on-disk (and iPhone↔Watch wire) JSON keys // stay `"splitID"`/`"splitName"` so existing files and a not-yet-updated peer still // decode. Every stored property must be enumerated here (the computed `relativePath` // and static members are not coded); all others keep their default key names. enum CodingKeys: String, CodingKey { case schemaVersion case id case routineID = "splitID" case routineName = "splitName" case start case end case status case createdAt case updatedAt case logs case metrics case deletedLogIDs case restSeconds case autoAdvance } // 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. // Bumped 3→4 when `WorkoutLogDocument.weight` went Int → Double and per-set // actuals (`setEntries`) were added: an older app decoding a fractional weight // into `Int` fails, and one rewriting the file would strip `setEntries` — // the recorded actuals are irreplaceable (same rationale as the v2 `metrics` // bump), so older apps must quarantine. // Bumped 4→5 when per-log `updatedAt` began being written and `deletedLogIDs` // was added for the per-log merge: an older app rewriting the file would strip // the deletion tombstones and resurrect a deleted exercise (irreplaceable // intent, same rationale as v2/v4), so older apps must quarantine. static let currentSchemaVersion = 5 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(now: Date = Date()) { 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 { // Only stamp the finish time on the *transition* into completed. A workout // that was already completed (with an `end`) keeps that original end, so // editing a finished workout — which re-runs this recompute with all logs // still resolved — doesn't jump its end forward to now. `now` is injectable // so the pure merge planner can recompute deterministically. let wasCompletedWithEnd = status == WorkoutStatus.completed.rawValue && end != nil status = WorkoutStatus.completed.rawValue if !wasCompletedWithEnd { end = now } } 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-routine 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: Double // unit-less plan snapshot; fractional since schema v4 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 /// Per-set actuals, in set order. `nil` → legacy file / nothing recorded; /// entries are appended as sets complete (pre-filled from the plan) and only /// diverge from it when the user adjusts one. Cleared on reset to `.notStarted`. var setEntries: [SetEntry]? = nil /// Reserved for the per-log merge H1's conflict resolution needs (a log edited /// on two devices merges by the newer `updatedAt`). Nothing writes it yet; /// absent in older files. var updatedAt: Date? = nil } extension WorkoutLogDocument { /// Build a fresh plan-time log from a routine'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 ) } /// Stamp the per-log modification time for a content-only edit — one that changes a /// log's fields (order, notes, machine settings, an adjusted entry) without flipping its /// status. Status flips stamp `updatedAt` inside `transition(to:)`; this is the hook the /// other edit sites call so the merge can order every kind of per-log change. mutating func touch(_ now: Date = Date()) { updatedAt = now } /// 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 // Stamp the per-log modification time on every status flip — the merge orders // concurrent edits by it. Content-only edits (order, notes, machine settings, // adjusted entries) stamp `updatedAt` at their own call sites. updatedAt = Date() switch newStatus { case .notStarted: // A full reset — the run never happened, so recorded actuals go too. startedAt = nil completedAt = nil setEntries = nil case .inProgress: if startedAt == nil { startedAt = Date() } completedAt = nil case .completed: if completedAt == nil { completedAt = Date() } // Sets that completed without an explicit record (list checkbox, // completeExercise) get plan-default entries; recorded ones are kept. fillSetEntries(upTo: sets, at: completedAt ?? Date()) case .skipped: // A skipped log keeps its partial entries — "End Workout, keeping // progress" must not discard the sets that were done. completedAt = nil } } /// Append plan-default entries for sets that completed without an explicit /// record, up to `count` in total. Only the missing tail is filled — entries /// already recorded are never overwritten — and a zero `count` on a log with /// no entries leaves `setEntries` nil (nothing was performed). mutating func fillSetEntries(upTo count: Int, at date: Date) { var entries = setEntries ?? [] guard entries.count < count else { return } while entries.count < count { entries.append(planSetEntry(completedAt: date)) } setEntries = entries } /// One plan-default entry, shaped by the log's `LoadType`: /// weight → reps + weight; none (bodyweight) → reps; duration → seconds. func planSetEntry(completedAt date: Date) -> SetEntry { switch LoadType(rawValue: loadType) ?? .weight { case .weight: SetEntry(reps: reps, weight: weight, completedAt: date) case .none: SetEntry(reps: reps, completedAt: date) case .duration: SetEntry(seconds: durationSeconds, completedAt: date) } } /// The entries read paths consume — the single fallback rule for legacy files: /// recorded actuals when any exist, else plan-synthesized entries for a /// *completed* log (what `fillSetEntries` would have recorded), else empty /// (an unfinished log with nothing recorded performed nothing). var effectiveSetEntries: [SetEntry] { if let setEntries, !setEntries.isEmpty { return setEntries } guard status == WorkoutStatus.completed.rawValue else { return [] } let date = completedAt ?? date return (0...json`. The schedule is the join object between routines and /// goals — scheduling never touches the routine file (starter routines are /// immutable fixed-ULID seeds). struct ScheduleDocument: Codable, Sendable, Equatable, Identifiable { var schemaVersion: Int var id: String // ULID var routineID: String var routineName: String // denormalized display fallback (routine may be deleted) var goal: String? // GoalKind raw value; nil = unassigned var recurrence: String // ScheduleRecurrence raw value var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) – 7 (Sat); .fixedDays only var date: Date? = nil // the one-off day; .once only var order: Int var createdAt: Date var updatedAt: Date static let currentSchemaVersion = 1 var relativePath: String { "Schedules/\(id).json" } } extension ScheduleDocument { /// The tagged goal, or nil when unassigned / an unknown raw value. var goalKind: GoalKind? { goal.flatMap(GoalKind.init(rawValue:)) } /// The recurrence, defaulting to `.daily` on an unknown raw value. var recurrenceEnum: ScheduleRecurrence { ScheduleRecurrence(rawValue: recurrence) ?? .daily } /// Human-readable recurrence line ("Jul 12, 2026" / "Daily" / "Mon & Thu"). var recurrenceSummary: String { recurrenceEnum.summary(weekdays: weekdays, date: 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 RoutineDocument: VersionedDocument {} extension WorkoutDocument: VersionedDocument {} extension ScheduleDocument: VersionedDocument {}