Files
workouts/Shared/Model/Documents.swift
T
rzen 06fa52345b Fix rebucketing, seed upgrades, watch pruning, end re-stamping; add model tests
Editing a workout's start date now removes the file at its old month
bucket so the record no longer duplicates on the next reconcile. Seed
reconcile re-checks the tombstone veto before overwriting an upgraded
seed. The watch applies authoritative-empty pushes so remote deletes
prune, and a re-saved finished workout keeps its original end time.
Adds unit tests for the mappers, path bucketing, and status machine.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
2026-07-08 07:54:10 -04:00

244 lines
11 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?
// 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.
static let currentSchemaVersion = 2
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)
/// 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
}
// 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.
// 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.
static let currentSchemaVersion = 3
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 {
// 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.
let wasCompletedWithEnd = status == WorkoutStatus.completed.rawValue && end != nil
status = WorkoutStatus.completed.rawValue
if !wasCompletedWithEnd { 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].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: Int
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
}
extension WorkoutLogDocument {
/// Build a fresh plan-time log from a split'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
)
}
/// 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
switch newStatus {
case .notStarted:
startedAt = nil
completedAt = nil
case .inProgress:
if startedAt == nil { startedAt = Date() }
completedAt = nil
case .completed:
if completedAt == nil { completedAt = Date() }
case .skipped:
completedAt = nil
}
}
}
// 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 {}