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
This commit is contained in:
2026-07-11 19:51:44 -04:00
parent b06a44eb40
commit 8854ad59d5
17 changed files with 283 additions and 10 deletions
+11 -4
View File
@@ -35,6 +35,11 @@ struct RoutineDocument: Codable, Sendable, Equatable, Identifiable {
/// which is preferable to quarantining the user's whole routine.
var restSeconds: Int? = nil
var autoAdvance: Bool? = nil
/// Target heart rate (bpm) for endurance routines drives the live too-low /
/// too-high indicator during a run. Nil no target. Only meaningful when the
/// `activityType` supports it (see `WorkoutActivityType.supportsHeartRateTarget`).
/// Optional and NOT schema-bumped, same rationale as the fields above.
var targetHeartRate: Int? = nil
// Bumped 12 when the weight-reminder fields (`weightLastUpdated`,
// `weightReminderWeeks`) and `category` were removed and `machineSettings` was
@@ -112,12 +117,13 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
/// 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`.
/// Snapshot of the routine's rest length / flow flag / target heart rate 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
var targetHeartRate: Int? = nil
// PINNED CODING KEYS: the Swift properties `routineID`/`routineName` were renamed
// from `splitID`/`splitName`, but their on-disk (and iPhoneWatch wire) JSON keys
@@ -139,6 +145,7 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
case deletedLogIDs
case restSeconds
case autoAdvance
case targetHeartRate
}
// Bumped 12 when `metrics` was added: the captured HR/calorie data is
+12
View File
@@ -11,6 +11,16 @@ import SwiftData
// 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
@@ -26,6 +36,7 @@ final class Routine {
var activityTypeRaw: Int = 0
var restSeconds: Int?
var autoAdvance: Bool?
var targetHeartRate: Int?
@Relationship(deleteRule: .cascade, inverse: \Exercise.routine)
var exercises: [Exercise] = []
@@ -152,6 +163,7 @@ final class Workout {
var deletedLogIDs: [String: Date]?
var restSeconds: Int?
var autoAdvance: Bool?
var targetHeartRate: Int?
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
var logs: [WorkoutLog] = []
+25
View File
@@ -78,6 +78,31 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
case .mindAndBody: "figure.mind.and.body"
}
}
/// Whether a routine of this type can carry a target heart rate. Steady-state
/// effort calibration ("raise the incline until you hit 140") only makes sense
/// for the endurance types a strength set's HR swings by design.
var supportsHeartRateTarget: Bool {
switch self {
case .hiit, .cardio, .cycling: true
default: false
}
}
}
/// Where a live heart-rate reading sits relative to a routine's target rate, with a
/// ±`tolerance` bpm dead band so the indicator doesn't flap around the boundary.
/// Shared by the phone and watch run screens so both classify identically.
enum HeartRateBand: Sendable {
case low, inRange, high
static let tolerance = 5
init(bpm: Double, target: Int) {
if bpm < Double(target - Self.tolerance) { self = .low }
else if bpm > Double(target + Self.tolerance) { self = .high }
else { self = .inRange }
}
}
/// Where a workout's health metrics came from: real watch sensors, or for records
+7 -3
View File
@@ -28,7 +28,8 @@ extension RoutineDocument {
createdAt: routine.createdAt, updatedAt: routine.updatedAt,
exercises: routine.exercisesArray.map(ExerciseDocument.init(from:)),
activityType: routine.activityTypeRaw,
restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance)
restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance,
targetHeartRate: routine.targetHeartRate)
}
}
@@ -52,7 +53,8 @@ extension WorkoutDocument {
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
metrics: workout.metrics,
deletedLogIDs: workout.deletedLogIDs,
restSeconds: workout.restSeconds, autoAdvance: workout.autoAdvance)
restSeconds: workout.restSeconds, autoAdvance: workout.autoAdvance,
targetHeartRate: workout.targetHeartRate)
}
/// Maps a *live* cache entity to a document, or `nil` when that entity has already
@@ -69,7 +71,7 @@ extension WorkoutDocument {
/// read still traps. A `@Model` retained across time (not freshly fetched) can reach
/// this map in that state, so check both.
init?(fromLive workout: Workout) {
guard !workout.isDeleted, workout.modelContext != nil else { return nil }
guard workout.isLive else { return nil }
self.init(from: workout)
}
@@ -140,6 +142,7 @@ enum CacheMapper {
routine.activityTypeRaw = doc.activityType ?? 0
routine.restSeconds = doc.restSeconds
routine.autoAdvance = doc.autoAdvance
routine.targetHeartRate = doc.targetHeartRate
let existing = Dictionary(routine.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>()
@@ -200,6 +203,7 @@ enum CacheMapper {
workout.deletedLogIDs = doc.deletedLogIDs
workout.restSeconds = doc.restSeconds
workout.autoAdvance = doc.autoAdvance
workout.targetHeartRate = doc.targetHeartRate
let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
var keep = Set<String>()