Watch sessions now run an HKLiveWorkoutBuilder: heart rate and active energy are collected live (shown in an in-workout HUD), saved to Health as a real HKWorkout on completion, and carried back to the phone as WorkoutMetrics on the workout document. Phone-only workouts get an estimated Health workout (MET x bodyweight x duration) after a 10s debounce that a late-arriving watch session cancels; a launch sweep backfills workouts completed within the last day. Dedupe is keyed on metrics.healthKitWorkoutUUID plus the metrics source. Splits gain an activity type (strength, functional, HIIT, core, cardio, cycling) that categorizes the Health workout and picks the MET value. A post-workout summary sheet (duration, calories, avg/max HR, HR zones, total volume) fills in live and is also shown on completed workouts. Weights can now display in lb or kg (display-only relabel), synced to the watch over the existing application context. WorkoutDocument schema 1->2 (metrics are irreplaceable, so older apps must quarantine rather than strip them); cache schema 2 rebuilds the SwiftData store with the new metric columns. Deleting a workout in the app intentionally leaves its Health record in place.
216 lines
8.0 KiB
Swift
216 lines
8.0 KiB
Swift
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/<ULID>.json`
|
||
/// • `WorkoutDocument` embeds its `[WorkoutLogDocument]` → `Workouts/YYYY/MM/<ULID>.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 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]
|
||
/// 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 currentSchema = 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
|
||
|
||
/// 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/<id>.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
|
||
}()
|
||
}
|