Every completed set now writes a SetEntry (reps/weight or seconds), pre-filled from the plan by transition(to:) so the list checkbox, both run flows, and One More all capture for free; reset clears, skip keeps partials. The rest and finish pages show the just-done set as a pill that opens a stepper sheet for correcting reps and weight (2.5 lb / 1.25 kg steps). The Weight Progression chart plots the top-set actual weight and workout volume sums recorded sets, both falling back to the plan for legacy logs via effectiveSetEntries. Storage side of UX #3 rides along: plan weights are Double now. Schema bumps: SplitDocument 2→3, WorkoutDocument 3→4 (a fractional weight fails an older Int decode, and a rewrite would strip the irreplaceable actuals), SwiftData cache 4→5. A per-log updatedAt is reserved for the future cross-device log merge. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
310 lines
14 KiB
Swift
310 lines
14 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?
|
||
|
||
// 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
|
||
|
||
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 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.
|
||
// 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.
|
||
static let currentSchemaVersion = 4
|
||
|
||
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: 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 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:
|
||
// 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..<max(0, sets)).map { _ in planSetEntry(completedAt: 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 {}
|