Files
workouts/Shared/Model/Entities.swift
T
rzen 8854ad59d5 Add per-routine target heart rate with live in-run indicator
Endurance routines (HIIT, cardio, cycling) can carry an optional target bpm
(RoutineDocument.targetHeartRate, not schema-bumped — same preference-field
rationale as restSeconds/autoAdvance), snapshotted onto the WorkoutDocument at
plan time like the other pacing fields.

During a run, the watch streams its live HR sample to the phone over a new
best-effort liveHeartRate message — deliberately outside the LiveProgress
machinery (no version bump, no staging/retry; a gauge, not a record), throttled
to changed-bpm-or-10s in the watch bridge. LiveRunState holds the sample with a
30s staleness auto-clear so a dead stream never shows a frozen number.

Both run screens show the reading only when the run carries a target: the
phone's ExerciseProgressView as a top pill, the watch's in the top-trailing
toolbar slot, each tinted by a shared ±5 bpm HeartRateBand with an arrow cue to
push harder (low) or ease off (high) — e.g. dialing in a treadmill incline to
hold a steady effort.

Claude-Session: https://claude.ai/code/session_01Y7ZhkCYWNiTSAFhFCGnJ8n
2026-07-11 19:51:44 -04:00

373 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 IndieSync
import Foundation
import SwiftData
// SwiftData cache entities. These are a rebuildable read-through cache of the
// iCloud Drive JSON documents — never the source of truth. App code reads them
// (via @Query); all writes go through `SyncEngine` to the document files, and the
// metadata observer mirrors the files back into these entities (see Mappers).
//
// `id` is the aggregate/child ULID (stable across cache rebuilds, unlike the
// SwiftData PersistentIdentifier). Computed helpers preserve the API the views
// used against the old Core Data classes.
extension PersistentModel {
/// True while this model is registered with a live context. Reading a persisted
/// property on a dead model traps, and any entity can die under a view that
/// retains it (remote delete, reconcile prune — the cache is rebuildable). Check
/// this before every such read: `isDeleted` alone misses the unregistered state
/// after the deletion saves, when `isDeleted` reads false again but reads still
/// trap (see `WorkoutDocument.init?(fromLive:)`).
var isLive: Bool { !isDeleted && modelContext != nil }
}
// MARK: - Routine
@Model
final class Routine {
@Attribute(.unique) var id: String = ULID.make()
var name: String = ""
var color: String = "indigo"
var systemImage: String = "dumbbell.fill"
var order: Int = 0
var createdAt: Date = Date()
var updatedAt: Date = Date()
var jsonRelativePath: String = ""
var activityTypeRaw: Int = 0
var restSeconds: Int?
var autoAdvance: Bool?
var targetHeartRate: Int?
@Relationship(deleteRule: .cascade, inverse: \Exercise.routine)
var exercises: [Exercise] = []
init(id: String, name: String, color: String, systemImage: String, order: Int,
createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
self.id = id
self.name = name
self.color = color
self.systemImage = systemImage
self.order = order
self.createdAt = createdAt
self.updatedAt = updatedAt
self.jsonRelativePath = jsonRelativePath
}
static let unnamed = "Unnamed Routine"
var exercisesArray: [Exercise] { exercises.sorted { $0.order < $1.order } }
var activityTypeEnum: WorkoutActivityType {
get { WorkoutActivityType(rawValue: activityTypeRaw) ?? .traditionalStrength }
set { activityTypeRaw = newValue.rawValue }
}
}
// MARK: - Exercise
@Model
final class Exercise {
@Attribute(.unique) var id: String = ULID.make()
var name: String = ""
var order: Int = 0
var sets: Int = 0
var reps: Int = 0
var weight: Double = 0
var loadType: Int = LoadType.weight.rawValue
var durationTotalSeconds: Int = 0
// Machine comfort settings — stored directly as an optional Codable array
// (SwiftData persists the `[MachineSetting]` composite), mirroring how
// `Workout` stores `hrZoneSeconds` as a plain `[Double]?`. `nil` → not a
// machine exercise; empty → a machine exercise with nothing recorded yet.
var machineSettings: [MachineSetting]?
var routine: Routine?
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Double,
loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = nil) {
self.id = id
self.name = name
self.order = order
self.sets = sets
self.reps = reps
self.weight = weight
self.loadType = loadType
self.durationTotalSeconds = durationTotalSeconds
self.machineSettings = machineSettings
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: loadType) ?? .weight }
set { loadType = newValue.rawValue }
}
/// One-line plan summary for list rows — duration exercises show their hold time
/// ("3 × 45 sec"), unloaded ones drop the weight ("3 × 12 reps"), weighted ones
/// keep the full "3 × 10 × 40 lb".
func planSummary(weightUnit: WeightUnit) -> String {
switch loadTypeEnum {
case .duration:
let m = durationMinutes, s = durationSeconds
let hold = m > 0 && s > 0 ? "\(m)m \(s)s" : (m > 0 ? "\(m) min" : "\(s) sec")
return "\(sets) × \(hold)"
case .none:
return "\(sets) × \(reps) reps"
case .weight:
return "\(sets) × \(reps) × \(weightUnit.format(weight))"
}
}
/// Minutes component of the total duration (for min:sec pickers).
var durationMinutes: Int {
get { durationTotalSeconds / 60 }
set { durationTotalSeconds = newValue * 60 + durationSeconds }
}
/// Seconds component (059) of the total duration.
var durationSeconds: Int {
get { durationTotalSeconds % 60 }
set { durationTotalSeconds = durationMinutes * 60 + newValue }
}
}
// MARK: - Workout
@Model
final class Workout {
@Attribute(.unique) var id: String = ULID.make()
var routineID: String?
var routineName: String?
var start: Date = Date()
var end: Date?
var statusRaw: String = WorkoutStatus.notStarted.rawValue
var createdAt: Date = Date()
var updatedAt: Date = Date()
var jsonRelativePath: String = ""
// Health metrics — flat columns mirroring `WorkoutMetrics` (SwiftData @Model
// can't store the nested Codable cleanly). Assemble via the `metrics` computed
// property. All nil until a workout finishes and a writer fills them in.
var metricActiveEnergyKcal: Double?
var metricAvgHeartRate: Double?
var metricMaxHeartRate: Double?
var metricMinHeartRate: Double?
var metricTotalVolume: Double?
var metricHRZoneSeconds: [Double]?
var metricHealthKitWorkoutUUID: String?
var metricSourceRaw: String?
var metricRecordedAt: Date?
/// Mirrors the document's per-log deletion tombstones (`logID → when deleted`), so the
/// phone re-derives them into any `WorkoutDocument` it rebuilds and the merge stays
/// deletion-safe across cache round-trips. Phone-authored; see `WorkoutMergePlanner`.
var deletedLogIDs: [String: Date]?
var restSeconds: Int?
var autoAdvance: Bool?
var targetHeartRate: Int?
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
var logs: [WorkoutLog] = []
init(id: String, routineID: String?, routineName: String?, start: Date, end: Date?,
statusRaw: String, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
self.id = id
self.routineID = routineID
self.routineName = routineName
self.start = start
self.end = end
self.statusRaw = statusRaw
self.createdAt = createdAt
self.updatedAt = updatedAt
self.jsonRelativePath = jsonRelativePath
}
var status: WorkoutStatus {
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
set { statusRaw = newValue.rawValue }
}
/// True while this workout is an untouched draft: created by a start tap, but no
/// exercise has ever left `.notStarted`. Peeking into a routine must leave no
/// trace, so the start paths discard a pristine draft when the user backs out of
/// it instead of persisting a phantom "in progress" workout.
var isPristineDraft: Bool {
status == .notStarted && logs.allSatisfy { $0.status == .notStarted }
}
var statusName: String { status.displayName }
var logsArray: [WorkoutLog] { logs.sorted { $0.order < $1.order } }
var label: String {
if status == .completed, let endDate = end {
if start.isSameDay(as: endDate) {
return "\(start.formattedDate())\(endDate.formattedTime())"
} else {
return "\(start.formattedDate())\(endDate.formattedDate())"
}
} else {
return start.formattedDate()
}
}
/// Assembles the flat `metric*` columns into a `WorkoutMetrics`, or nil when none
/// have been recorded. `source`/`recordedAt` are always set together when metrics
/// exist, so they gate presence.
var metrics: WorkoutMetrics? {
get {
guard let sourceRaw = metricSourceRaw,
let source = MetricSource(rawValue: sourceRaw),
let recordedAt = metricRecordedAt else { return nil }
return WorkoutMetrics(
activeEnergyKcal: metricActiveEnergyKcal,
avgHeartRate: metricAvgHeartRate,
maxHeartRate: metricMaxHeartRate,
minHeartRate: metricMinHeartRate,
totalVolume: metricTotalVolume,
hrZoneSeconds: metricHRZoneSeconds,
healthKitWorkoutUUID: metricHealthKitWorkoutUUID,
source: source,
recordedAt: recordedAt)
}
set {
metricActiveEnergyKcal = newValue?.activeEnergyKcal
metricAvgHeartRate = newValue?.avgHeartRate
metricMaxHeartRate = newValue?.maxHeartRate
metricMinHeartRate = newValue?.minHeartRate
metricTotalVolume = newValue?.totalVolume
metricHRZoneSeconds = newValue?.hrZoneSeconds
metricHealthKitWorkoutUUID = newValue?.healthKitWorkoutUUID
metricSourceRaw = newValue?.source.rawValue
metricRecordedAt = newValue?.recordedAt
}
}
}
// MARK: - WorkoutLog
@Model
final class WorkoutLog {
@Attribute(.unique) var id: String = ULID.make()
var exerciseName: String = ""
var order: Int = 0
var sets: Int = 0
var reps: Int = 0
var weight: Double = 0
var loadType: Int = LoadType.weight.rawValue
var durationTotalSeconds: Int = 0
var currentStateIndex: Int = 0
var statusRaw: String = WorkoutStatus.notStarted.rawValue
var notes: String?
var date: Date = Date()
// Snapshot of the exercise's machine comfort settings at plan time — stored
// directly as an optional Codable array, same pattern as `Exercise`.
var machineSettings: [MachineSetting]?
var startedAt: Date?
var completedAt: Date?
// Per-set actuals — stored directly as an optional Codable array, same
// pattern as `machineSettings`. `nil` → legacy file / nothing recorded.
var setEntries: [SetEntry]?
// Mirrors the document's per-log `updatedAt` (named to dodge a future
// SwiftData column collision). Reserved for H1's per-log merge; unused.
var logUpdatedAt: Date?
var workout: Workout?
init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Double,
loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int,
statusRaw: String, notes: String?, date: Date,
machineSettings: [MachineSetting]? = nil,
startedAt: Date? = nil, completedAt: Date? = nil,
setEntries: [SetEntry]? = nil, logUpdatedAt: Date? = nil) {
self.id = id
self.exerciseName = exerciseName
self.order = order
self.sets = sets
self.reps = reps
self.weight = weight
self.loadType = loadType
self.durationTotalSeconds = durationTotalSeconds
self.currentStateIndex = currentStateIndex
self.statusRaw = statusRaw
self.notes = notes
self.date = date
self.machineSettings = machineSettings
self.startedAt = startedAt
self.completedAt = completedAt
self.setEntries = setEntries
self.logUpdatedAt = logUpdatedAt
}
var status: WorkoutStatus {
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
set { statusRaw = newValue.rawValue }
}
var loadTypeEnum: LoadType {
get { LoadType(rawValue: loadType) ?? .weight }
set { loadType = newValue.rawValue }
}
var durationMinutes: Int {
get { durationTotalSeconds / 60 }
set { durationTotalSeconds = newValue * 60 + durationSeconds }
}
var durationSeconds: Int {
get { durationTotalSeconds % 60 }
set { durationTotalSeconds = durationMinutes * 60 + newValue }
}
}
// MARK: - Schedule
@Model
final class Schedule {
@Attribute(.unique) var id: String = ULID.make()
var routineID: String = ""
var routineName: String = ""
var goalRaw: String?
var recurrenceRaw: String = ScheduleRecurrence.daily.rawValue
var weekdays: [Int]?
var date: Date?
/// Reminder time as minutes from midnight (local wall-clock); nil = no reminder.
var reminderMinutes: Int?
var order: Int = 0
var createdAt: Date = Date()
var updatedAt: Date = Date()
var jsonRelativePath: String = ""
init(id: String, routineID: String, routineName: String, goalRaw: String?,
recurrenceRaw: String, weekdays: [Int]?, date: Date? = nil, reminderMinutes: Int? = nil,
order: Int, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
self.id = id
self.routineID = routineID
self.routineName = routineName
self.goalRaw = goalRaw
self.recurrenceRaw = recurrenceRaw
self.weekdays = weekdays
self.date = date
self.reminderMinutes = reminderMinutes
self.order = order
self.createdAt = createdAt
self.updatedAt = updatedAt
self.jsonRelativePath = jsonRelativePath
}
var goalKind: GoalKind? {
get { goalRaw.flatMap(GoalKind.init(rawValue:)) }
set { goalRaw = newValue?.rawValue }
}
var recurrenceEnum: ScheduleRecurrence {
get { ScheduleRecurrence(rawValue: recurrenceRaw) ?? .daily }
set { recurrenceRaw = newValue.rawValue }
}
/// Human-readable recurrence line — shares its formatting with `ScheduleDocument`
/// via `ScheduleRecurrence.summary(weekdays:date:)`.
var recurrenceSummary: String {
recurrenceEnum.summary(weekdays: weekdays, date: date)
}
}