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
+20
View File
@@ -23,6 +23,7 @@ enum WCPayload {
static let requestSyncType = "requestSync" // watch phone (please push state)
static let liveProgressType = "liveProgress" // watch phone (ephemeral mirror frame)
static let liveEndedType = "liveEnded" // watch phone (stop mirroring a run)
static let liveHeartRateType = "liveHeartRate" // watch phone (ephemeral HR sample)
// MARK: - Phone Watch (application context: latest-state-wins)
@@ -138,4 +139,23 @@ enum WCPayload {
static func encodeLiveEnded(workoutID: String, logID: String) -> [String: Any] {
[typeKey: liveEndedType, lpWorkoutIDKey: workoutID, lpLogIDKey: logID]
}
// MARK: - Watch Phone (ephemeral live heart rate)
// Deliberately outside the `LiveProgress` machinery: HR is a device-level gauge
// (the wearer's pulse, not a run position), updates far more often than phase
// transitions, and must never bump the frame version sequence or re-anchor
// timers. Best-effort, latest-wins, display-only a dropped sample just means
// the readout refreshes on the next one.
static let hrBpmKey = "hrBpm"
static let hrAtKey = "hrAt"
static func encodeLiveHeartRate(bpm: Double, at date: Date) -> [String: Any] {
[typeKey: liveHeartRateType, hrBpmKey: bpm, hrAtKey: date]
}
static func decodeLiveHeartRate(_ dict: [String: Any]) -> (bpm: Double, at: Date)? {
guard let bpm = dict[hrBpmKey] as? Double, let at = dict[hrAtKey] as? Date else { return nil }
return (bpm, at)
}
}