- 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)
409 lines
19 KiB
Swift
409 lines
19 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:
|
||
/// • `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 {}
|