Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions Strand/Data/Repository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Strand/Screens/LiveView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Strand/Screens/LiveWorkoutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion Strand/Screens/WorkoutDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
15 changes: 11 additions & 4 deletions android/app/src/main/java/com/noop/analytics/HrZones.kt
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,18 @@ object HrZones {
*/
internal class HrZoneSetCache {
private var maxHR = Double.NaN
private var custom: List<Double>? = 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<Double>? = 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
}
}
}
11 changes: 6 additions & 5 deletions android/app/src/main/java/com/noop/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion android/app/src/main/java/com/noop/ui/AutomationsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion android/app/src/main/java/com/noop/ui/LiveScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion android/app/src/main/java/com/noop/ui/LiveWorkoutScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down