Files
workouts/Shared/Model/Documents.swift
T
rzen 7cb2d6da26 Show only the day's docket on the Today board and add ad hoc Now workouts
- Due-filter schedules: daily always, fixed days by weekday, one-offs on
  their date; rest days get an empty state
- Workouts with no due schedule row (ad hoc or off-day starts) now render
  as their own board rows
- The + sheet is now "New Workout" with a "When" picker; "Now" (offered
  when adding on today) skips the schedule and starts the workout directly
- Remove the times-per-week scheduling mode everywhere (enum, document,
  entity, mappers, planner, seeds, tests)
2026-07-11 11:09:11 -04:00

409 lines
19 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:
/// • `RoutineDocument` 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: - Routine
struct RoutineDocument: 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?
/// Per-routine rest length (seconds) and flow-mode toggle. Both optional and
/// deliberately NOT schema-bumped, same rationale as `activityType`: an older
/// app dropping them on rewrite reverts to the global default rest / no-flow,
/// which is preferable to quarantining the user's whole routine.
var restSeconds: Int? = nil
var autoAdvance: Bool? = nil
// 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
// PINNED: the iCloud directory name stays "Splits/" even though the type is now
// `RoutineDocument`. Existing user files live under Splits/ — the directory name
// is on-disk data, not a symbol, and must not change on the rename.
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 routineID: String?
var routineName: 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?
/// Per-log deletion tombstones — `logID → when deleted`. Authored only by the phone
/// (`WorkoutLogListView.deleteLog`, the sole log-removal site). They disambiguate an
/// absent log during the per-log merge: without them, a log the watch still carries
/// (having missed a phone-side delete) is indistinguishable from one the watch just
/// added. `nil`/absent in files written before the merge shipped; pruned after a grace
/// period. See `WorkoutMergePlanner`.
var deletedLogIDs: [String: Date]? = nil
/// Snapshot of the routine's rest length / flow flag at plan time (a running
/// workout has no live link to its routine, same reason sets/reps/weight are
/// snapshotted per log). Optional and not schema-bumped, same rationale as
/// `RoutineDocument.restSeconds`/`autoAdvance`.
var restSeconds: Int? = nil
var autoAdvance: Bool? = nil
// PINNED CODING KEYS: the Swift properties `routineID`/`routineName` were renamed
// from `splitID`/`splitName`, but their on-disk (and iPhone↔Watch wire) JSON keys
// stay `"splitID"`/`"splitName"` so existing files and a not-yet-updated peer still
// decode. Every stored property must be enumerated here (the computed `relativePath`
// and static members are not coded); all others keep their default key names.
enum CodingKeys: String, CodingKey {
case schemaVersion
case id
case routineID = "splitID"
case routineName = "splitName"
case start
case end
case status
case createdAt
case updatedAt
case logs
case metrics
case deletedLogIDs
case restSeconds
case autoAdvance
}
// 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.
// Bumped 4→5 when per-log `updatedAt` began being written and `deletedLogIDs`
// was added for the per-log merge: an older app rewriting the file would strip
// the deletion tombstones and resurrect a deleted exercise (irreplaceable
// intent, same rationale as v2/v4), so older apps must quarantine.
static let currentSchemaVersion = 5
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(now: Date = Date()) {
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. `now` is injectable
// so the pure merge planner can recompute deterministically.
let wasCompletedWithEnd = status == WorkoutStatus.completed.rawValue && end != nil
status = WorkoutStatus.completed.rawValue
if !wasCompletedWithEnd { end = now }
} 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-routine 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 routine'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
)
}
/// Stamp the per-log modification time for a content-only edit — one that changes a
/// log's fields (order, notes, machine settings, an adjusted entry) without flipping its
/// status. Status flips stamp `updatedAt` inside `transition(to:)`; this is the hook the
/// other edit sites call so the merge can order every kind of per-log change.
mutating func touch(_ now: Date = Date()) { updatedAt = now }
/// 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
// Stamp the per-log modification time on every status flip — the merge orders
// concurrent edits by it. Content-only edits (order, notes, machine settings,
// adjusted entries) stamp `updatedAt` at their own call sites.
updatedAt = Date()
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: - Schedule
/// The plan layer: one routine + a recurrence + an optional goal tag, persisted as
/// `Schedules/<ULID>.json`. The schedule is the join object between routines and
/// goals — scheduling never touches the routine file (starter routines are
/// immutable fixed-ULID seeds).
struct ScheduleDocument: Codable, Sendable, Equatable, Identifiable {
var schemaVersion: Int
var id: String // ULID
var routineID: String
var routineName: String // denormalized display fallback (routine may be deleted)
var goal: String? // GoalKind raw value; nil = unassigned
var recurrence: String // ScheduleRecurrence raw value
var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) 7 (Sat); .fixedDays only
var date: Date? = nil // the one-off day; .once only
var order: Int
var createdAt: Date
var updatedAt: Date
static let currentSchemaVersion = 1
var relativePath: String { "Schedules/\(id).json" }
}
extension ScheduleDocument {
/// The tagged goal, or nil when unassigned / an unknown raw value.
var goalKind: GoalKind? { goal.flatMap(GoalKind.init(rawValue:)) }
/// The recurrence, defaulting to `.daily` on an unknown raw value.
var recurrenceEnum: ScheduleRecurrence { ScheduleRecurrence(rawValue: recurrence) ?? .daily }
/// Human-readable recurrence line ("Jul 12, 2026" / "Daily" / "Mon & Thu").
var recurrenceSummary: String {
recurrenceEnum.summary(weekdays: weekdays, date: 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 RoutineDocument: VersionedDocument {}
extension WorkoutDocument: VersionedDocument {}
extension ScheduleDocument: VersionedDocument {}