Exercise Library/ holds per-exercise reference docs (setup, cues, mistakes, progressions) with SVG visuals and a Python-rendered motion pipeline; Workouts/ExerciseFigure renders the bundled *.motion.json rigs as animated stick figures on the exercise screen. Exercises gain a warm-up/main-circuit category, timed exercises display hold time via planSummary, and a completed exercise reopens to a check screen instead of its timers.
170 lines
6.8 KiB
Swift
170 lines
6.8 KiB
Swift
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/<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 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 {}
|