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:
@@ -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 1→2 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 iPhone↔Watch wire) JSON keys
|
||||
@@ -139,6 +145,7 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
case deletedLogIDs
|
||||
case restSeconds
|
||||
case autoAdvance
|
||||
case targetHeartRate
|
||||
}
|
||||
|
||||
// Bumped 1→2 when `metrics` was added: the captured HR/calorie data is
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
|
||||
Reference in New Issue
Block a user