Files
workouts/Shared/Model/Documents.swift
T
rzen 394ec0989e Record per-set actuals and drive the chart and volume from them
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
2026-07-08 12:48:37 -04:00

310 lines
14 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.
// 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 {}