Files
workouts/Shared/Model/Documents.swift
T
rzen 1f2df491db Migrate the sync file layer onto the IndieSync package
Replaces the app-local copies of the extracted storage core with the
IndieSync 0.1.0 package (pinned from the new tag): ICloudFileManager ->
DocumentFileStore + TombstoneStore, ICloudFileMonitor ->
MetadataObserver (batched events with the content-date churn gate),
and the shared ULID / DocumentCoder / Tombstone / VersionedDocument
pieces. SyncEngine stays app-specific and is rewired onto the package
types; documents rename currentSchema -> currentSchemaVersion to adopt
the package protocol. ULID.make() remains as a shim minting the string
form the app keys on.

Behavior-preserving: identical JSON bytes (same coder config), same
stub wire format (kind encodes as the same strings), same soft-delete
ordering, same 30-day prune. WorkoutDocument keeps its local-calendar
month bucketing rather than adopting TimeBucketedLayout's UTC paths --
changing derivation would strand existing files. The watch target
links the package too (ULID + DocumentCoder via Shared); iOS, watchOS,
and widget targets all build.
2026-07-05 08:04:29 -04:00

166 lines
6.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// 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 {}