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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user