diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 5f39d18033..42f4536953 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -1467,10 +1467,10 @@ final class AppModel: ObservableObject { /// HR-zone haptic coaching: buzz when crossing into the top zone (ease off) or back to recovery. private func coachZone(_ hr: Int?) { guard behavior.zoneCoaching, live.bonded, live.worn, let hr, hr >= 30 else { return } - let maxHR = Double(profile.hrMax) - guard maxHR > 0 else { return } - let pct = Double(hr) / maxHR - let zone = pct >= 0.9 ? 5 : pct >= 0.8 ? 4 : pct >= 0.7 ? 3 : pct >= 0.6 ? 2 : 1 + guard profile.hrMax > 0 else { return } + // #531: route the haptic coach through the profile's effective zone set (personalized when set, + // conventional %HRmax otherwise) instead of hardcoded percentage bands. + let zone = profile.hrZoneSet.zoneNumber(forBPM: Double(hr)) defer { lastCoachZone = zone } guard lastCoachZone != -1, zone != lastCoachZone else { return } if zone == 5, lastCoachZone < 5 { buzz(loops: 3) } // entered max , ease off diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index aa9433d951..db1f7ab0ed 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -2729,13 +2729,12 @@ final class Repository: ObservableObject { /// day-level union, so a bout detected on a second WHOOP had its zones computed from a strap that /// never recorded it. `source` defaults to "" (⇒ the imported branch, the union) so a caller /// without a row keeps today's behaviour. - func workoutZoneMinutes(from: Int, to: Int, age: Int, source: String = "") async -> [Double]? { + func workoutZoneMinutes(from: Int, to: Int, zoneSet: HRZoneSet, source: String = "") async -> [Double]? { guard to > from else { return nil } let ids = Self.workoutHrDeviceIds(source: source, activeStrapId: deviceId, importedIds: importedReadIds) let samples = await hrSamples(deviceIds: ids, from: from, to: to) guard !samples.isEmpty else { return nil } - let zoneSet = HRZones.zones(age: age > 0 ? Double(age) : 30) let tiz = HRZones.timeInZone(samples, zoneSet: zoneSet) let minutes = tiz.seconds.map { $0 / 60.0 } return minutes.contains(where: { $0 > 0 }) ? minutes : nil diff --git a/Strand/Screens/LiveView.swift b/Strand/Screens/LiveView.swift index ed1d239a4c..65d63e34cc 100644 --- a/Strand/Screens/LiveView.swift +++ b/Strand/Screens/LiveView.swift @@ -757,7 +757,7 @@ private struct LiveHeartReadout: View { /// The live HR zone for the focal readout's colour world (presentation only). 0 = below Zone 1. private var liveZone: Int { guard let bpm = displayHR else { return 0 } - return HRZones.zones(maxHR: Double(hrMax)).zoneNumber(forBPM: Double(bpm)) + return model.profile.hrZoneSet.zoneNumber(forBPM: Double(bpm)) } /// The focal vessel / numeral colour: the live HR-zone hue when streaming, the Effort world otherwise. diff --git a/Strand/Screens/LiveWorkoutView.swift b/Strand/Screens/LiveWorkoutView.swift index 10ab5fc274..cb035c0357 100644 --- a/Strand/Screens/LiveWorkoutView.swift +++ b/Strand/Screens/LiveWorkoutView.swift @@ -38,7 +38,7 @@ struct LiveWorkoutView: View { /// control must not end the workout instantly with no way back. @State private var showEndConfirm = false - private var zoneSet: HRZoneSet { HRZones.zones(maxHR: Double(model.profile.hrMax)) } + private var zoneSet: HRZoneSet { model.profile.hrZoneSet } private var zone: Int { model.bpm.map { zoneSet.zoneNumber(forBPM: Double($0)) } ?? 0 } var body: some View { diff --git a/Strand/Screens/WorkoutDetailView.swift b/Strand/Screens/WorkoutDetailView.swift index 4acdb2501f..cf6b4c75d9 100644 --- a/Strand/Screens/WorkoutDetailView.swift +++ b/Strand/Screens/WorkoutDetailView.swift @@ -127,7 +127,7 @@ struct WorkoutDetailView: View { } } if minutes == nil { - minutes = await repo.workoutZoneMinutes(from: row.startTs, to: row.endTs, age: profile.age, + minutes = await repo.workoutZoneMinutes(from: row.startTs, to: row.endTs, zoneSet: profile.hrZoneSet, source: row.source) } diff --git a/android/app/src/main/java/com/noop/analytics/HrZones.kt b/android/app/src/main/java/com/noop/analytics/HrZones.kt index 4c6c21fcac..0d6316d251 100644 --- a/android/app/src/main/java/com/noop/analytics/HrZones.kt +++ b/android/app/src/main/java/com/noop/analytics/HrZones.kt @@ -230,11 +230,18 @@ object HrZones { */ internal class HrZoneSetCache { private var maxHR = Double.NaN + private var custom: List? = null private var set: HrZoneSet? = null - /** The `manual`-source zone set for [maxHR], rebuilt only when [maxHR] differs from the last call. */ - fun zones(maxHR: Double): HrZoneSet { - set?.let { if (maxHR == this.maxHR) return it } - return HrZones.zones(maxHR = maxHR).also { this.maxHR = maxHR; set = it } + /** + * The effective zone set for [maxHR] and optional [customLowerBounds], rebuilt only when an input + * differs from the last call — so the 1 Hz coach path reuses it, and personalized zones (#531) are + * honoured instead of the maxHR-only default. + */ + fun zones(maxHR: Double, customLowerBounds: List? = null): HrZoneSet { + set?.let { if (maxHR == this.maxHR && customLowerBounds == custom) return it } + return HrZones.zones(maxHR = maxHR, customLowerBounds = customLowerBounds).also { + this.maxHR = maxHR; custom = customLowerBounds; set = it + } } } diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index b55786b95e..0b0a3095fc 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -1746,8 +1746,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { val ids = WhoopRepository.workoutHrDeviceIds(source, rowDeviceId, deviceId) val samples = runCatching { repository.hrSamplesFor(ids, from, to) }.getOrDefault(emptyList()) if (samples.isEmpty()) return null - val age = profileStore.age.toDouble().takeIf { it > 0 } ?: 30.0 - val zoneSet = com.noop.analytics.HrZones.zones(age = age) + val zoneSet = profileStore.hrZoneSet val tiz = com.noop.analytics.HrZones.timeInZone(samples, zoneSet) val minutes = tiz.seconds.map { it / 60.0 } return if (minutes.any { it > 0.0 }) minutes else null @@ -2609,9 +2608,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { if (hr < 30) return val maxHR = profileStore.hrMax.toDouble() if (maxHR <= 0) return - // Memoized on maxHR: this runs every ~1 Hz live-HR sample while zone coaching is active, but the - // zone set only changes when the user edits their max HR — so don't rebuild it per tick. - val zone = zoneSetCache.zones(maxHR).zoneNumber(hr.toDouble()) + // Memoized on maxHR + personalized bounds: this runs every ~1 Hz live-HR sample while zone + // coaching is active, but the zone set only changes when the user edits their max HR or custom + // zones — so don't rebuild it per tick. + val zone = zoneSetCache.zones(maxHR, profileStore.hrZoneThresholds?.map(Int::toDouble)) + .zoneNumber(hr.toDouble()) val previous = lastZone lastZone = zone val loops = zoneCoachBuzzLoops(previous, zone, _zoneCoachRecovery.value) diff --git a/android/app/src/main/java/com/noop/ui/AutomationsScreen.kt b/android/app/src/main/java/com/noop/ui/AutomationsScreen.kt index 0fca2ba37e..7df721ed99 100644 --- a/android/app/src/main/java/com/noop/ui/AutomationsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/AutomationsScreen.kt @@ -86,7 +86,7 @@ fun AutomationsScreen(viewModel: AppViewModel) { val zoneCoachRecovery by viewModel.zoneCoachRecovery.collectAsStateWithLifecycle() // The Zone 5 entry threshold (≥ 90% of HR-max), from the same HrZones model used everywhere. val zone5Bpm = remember(profile.hrMax) { - HrZones.zones(maxHR = profile.hrMax.toDouble()).zones.firstOrNull { it.number == 5 }?.lower?.roundToInt() ?: 0 + profile.hrZoneSet.zones.firstOrNull { it.number == 5 }?.lower?.roundToInt() ?: 0 } // Inactivity reminder (#419) — real + persisted via InactivityPrefs (opt-in, default OFF). Seeded diff --git a/android/app/src/main/java/com/noop/ui/LiveScreen.kt b/android/app/src/main/java/com/noop/ui/LiveScreen.kt index 0ac5b74019..3bb9a72a7a 100644 --- a/android/app/src/main/java/com/noop/ui/LiveScreen.kt +++ b/android/app/src/main/java/com/noop/ui/LiveScreen.kt @@ -136,7 +136,7 @@ fun LiveScreen(viewModel: AppViewModel, onManageDevices: () -> Unit = {}) { // Live HR zone for the focal readout's colour world (presentation only — same shared HrZones model // the live-workout screen uses). 0 = below Zone 1 / no HR yet. val profile = remember { ProfileStore.from(context.applicationContext) } - val zoneSet = remember(profile.hrMax) { HrZones.zones(maxHR = profile.hrMax.toDouble()) } + val zoneSet = remember(profile.hrMax, profile.hrZoneThresholds) { profile.hrZoneSet } val liveZone = bpm?.let { zoneSet.zoneNumber(it.toDouble()) } ?: 0 // HR-zone coaching state, shown read-only here; the toggles live in Automations. diff --git a/android/app/src/main/java/com/noop/ui/LiveWorkoutScreen.kt b/android/app/src/main/java/com/noop/ui/LiveWorkoutScreen.kt index 28b686d204..330564d583 100644 --- a/android/app/src/main/java/com/noop/ui/LiveWorkoutScreen.kt +++ b/android/app/src/main/java/com/noop/ui/LiveWorkoutScreen.kt @@ -96,7 +96,7 @@ fun LiveWorkoutScreen(vm: AppViewModel, onClose: () -> Unit) { LaunchedEffect(w == null) { if (w == null) onClose() } if (w == null) return - val zoneSet = remember(profile.hrMax) { HrZones.zones(maxHR = profile.hrMax.toDouble()) } + val zoneSet = remember(profile.hrMax, profile.hrZoneThresholds) { profile.hrZoneSet } val zone = bpm?.let { zoneSet.zoneNumber(it.toDouble()) } ?: 0 // Guards the destructive End action behind a confirm (#517) — a stray tap on the full-width