diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift index 6a3793a32c..e4bca676a4 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift @@ -41,13 +41,14 @@ public struct HRZone: Equatable, Sendable { } } -/// Five HR zones derived from a max HR, plus the max HR itself and its source. +/// Five HR zones derived from a max HR or personalized BPM boundaries, plus the max HR itself and +/// its source. public struct HRZoneSet: Equatable, Sendable { /// The five zones, z1...z5, in ascending order. public let zones: [HRZone] /// Max HR (bpm) the zones were built from. public let maxHR: Double - /// "tanaka" (age formula) or "manual" (caller override). + /// "tanaka" (age formula), "manual" (caller override), or "custom" (personalized boundaries). public let source: String public init(zones: [HRZone], maxHR: Double, source: String) { @@ -97,6 +98,10 @@ public enum HRZones { /// %HRmax band edges for zones 1...5: [0.50, 0.60, 0.70, 0.80, 0.90, 1.00]. public static let zoneEdges: [Double] = [0.50, 0.60, 0.70, 0.80, 0.90, 1.00] + /// Sensible editable BPM range for personalized zone starts. The analytics API accepts any + /// positive finite values; the app UIs use this range to keep steppers practical. + public static let customBPMRange: ClosedRange = 30...250 + /// Tanaka (2001) age-predicted max HR: 208 − 0.7 × age (gender-independent). public static func tanakaMaxHR(age: Double) -> Double { 208.0 - 0.7 * age @@ -107,7 +112,9 @@ public enum HRZones { /// - Parameters: /// - age: age in years (used only when `maxHROverride` is nil). /// - maxHROverride: explicit HRmax (bpm); when provided, `source == "manual"`. - public static func zones(age: Double, maxHROverride: Double? = nil) -> HRZoneSet { + public static func zones(age: Double, + maxHROverride: Double? = nil, + customLowerBounds: [Double]? = nil) -> HRZoneSet { let maxHR: Double let source: String if let override = maxHROverride { @@ -117,24 +124,48 @@ public enum HRZones { maxHR = tanakaMaxHR(age: age) source = "tanaka" } - return zones(maxHR: maxHR, source: source) + return zones(maxHR: maxHR, source: source, customLowerBounds: customLowerBounds) } - /// Build the 5-zone set directly from a known max HR. - public static func zones(maxHR: Double, source: String = "manual") -> HRZoneSet { + /// Build the 5-zone set directly from a known max HR, optionally replacing the conventional + /// percentage edges with five personalized inclusive lower bounds in BPM. Invalid custom input + /// falls back to the conventional model, so malformed restored preferences can never create gaps. + public static func zones(maxHR: Double, + source: String = "manual", + customLowerBounds: [Double]? = nil) -> HRZoneSet { + let custom = customLowerBounds.flatMap(validCustomLowerBounds) var built: [HRZone] = [] for i in 0..<5 { - let loPct = zoneEdges[i] - let hiPct = zoneEdges[i + 1] + let lower = custom?[i] ?? zoneEdges[i] * maxHR + let upper = custom.map { i < 4 ? $0[i + 1] : max(maxHR, $0[i]) } + ?? zoneEdges[i + 1] * maxHR + let loPct = maxHR > 0 ? lower / maxHR : 0 + let hiPct = maxHR > 0 ? upper / maxHR : 0 built.append(HRZone( number: i + 1, - lower: loPct * maxHR, - upper: hiPct * maxHR, + lower: lower, + upper: upper, lowerPct: loPct, upperPct: hiPct )) } - return HRZoneSet(zones: built, maxHR: maxHR, source: source) + return HRZoneSet(zones: built, maxHR: maxHR, source: custom == nil ? source : "custom") + } + + /// The conventional five inclusive lower bounds, rounded up to whole BPM for an editor. Rounding + /// up preserves the existing integer-sample classification (e.g. a 93.5 edge starts at 94 bpm). + public static func defaultLowerBounds(maxHR: Double) -> [Int] { + Array(zoneEdges.prefix(5)).map { Int(ceil($0 * maxHR)) } + } + + /// Return a valid five-boundary custom model, or nil unless values are positive, finite, and + /// strictly increasing. Kept public so persistence layers can reject hand-edited backup values + /// using the exact same invariant as the analytics engine. + public static func validCustomLowerBounds(_ values: [Double]) -> [Double]? { + guard values.count == 5, + values.allSatisfy({ $0.isFinite && $0 > 0 }) else { return nil } + for i in 1..= 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 } + 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/Profile.swift b/Strand/Data/Profile.swift index d5e843fb0b..029ea4e297 100644 --- a/Strand/Data/Profile.swift +++ b/Strand/Data/Profile.swift @@ -1,6 +1,7 @@ import Foundation import Combine import SwiftUI +import StrandAnalytics /// User profile (age/sex/body metrics/HR-max) persisted in UserDefaults. /// Powers HR zones, calories and recovery baselines. @@ -26,6 +27,13 @@ final class ProfileStore: ObservableObject { @Published var waistCm: Double { didSet { d.set(waistCm, forKey: K.waist) } } /// 0 = auto-estimate from age. @Published var hrMaxOverride: Int { didSet { d.set(hrMaxOverride, forKey: K.hrMax) } } + /// Five personalized inclusive zone starts in BPM; empty = conventional %HRmax zones. + @Published var hrZoneThresholds: [Int] { + didSet { + if hrZoneThresholds.isEmpty { d.removeObject(forKey: K.hrZoneThresholds) } + else { d.set(hrZoneThresholds.map(String.init).joined(separator: ","), forKey: K.hrZoneThresholds) } + } + } /// Step-calibration divisor (#139/#132): counter ticks per real step for the @57 motion /// counter. 1.0 = raw pass-through (default — no behavior change). Clamped 0.5–30.0 /// (WHOOP 5/MG motion-counter overcount can reach ~24×, so the ceiling has to be high). @@ -69,6 +77,7 @@ final class ProfileStore: ObservableObject { static let legacyAge = "profile.age" static let sex = "profile.sex", weight = "profile.weightKg" static let height = "profile.heightCm", hrMax = "profile.hrMaxOverride" + static let hrZoneThresholds = "profile.hrZoneThresholds" static let stepScale = "profile.stepTicksPerStep" static let waist = "profile.waistCm" static let stepsCoeff = "profile.stepsCalibrationCoefficient" @@ -106,6 +115,9 @@ final class ProfileStore: ObservableObject { heightCm = d.object(forKey: K.height) as? Double ?? 178 waistCm = d.object(forKey: K.waist) as? Double ?? 0 hrMaxOverride = d.object(forKey: K.hrMax) as? Int ?? 0 + let storedThresholds = d.string(forKey: K.hrZoneThresholds)? + .split(separator: ",").compactMap { Int($0) } ?? [] + hrZoneThresholds = Self.validZoneThresholds(storedThresholds) ? storedThresholds : [] stepTicksPerStep = min(max(d.object(forKey: K.stepScale) as? Double ?? 1.0, 0.5), 30.0) stepsCalibrationCoefficient = d.object(forKey: K.stepsCoeff) as? Double ?? 0 stepsCalibrationSampleDays = d.object(forKey: K.stepsSampleDays) as? Int ?? 0 @@ -171,6 +183,42 @@ final class ProfileStore: ObservableObject { /// Tanaka estimate unless overridden. var hrMax: Int { hrMaxOverride > 0 ? hrMaxOverride : Int((208 - 0.7 * Double(age)).rounded()) } + /// Personalized zone starts after enforcing the same five-value invariant as `HRZones`. + var customHRZoneLowerBounds: [Double]? { + guard Self.validZoneThresholds(hrZoneThresholds) else { return nil } + return hrZoneThresholds.map(Double.init) + } + + /// The single display-zone model used by live HR, workout splits, and haptic coaching. + var hrZoneSet: HRZoneSet { + HRZones.zones(maxHR: Double(hrMax), customLowerBounds: customHRZoneLowerBounds) + } + + var hasCustomHRZones: Bool { customHRZoneLowerBounds != nil } + + /// Enable by seeding the editor with boundaries that classify integer BPM exactly like today's + /// conventional percentages; disabling removes the override and immediately restores defaults. + func setCustomHRZonesEnabled(_ enabled: Bool) { + hrZoneThresholds = enabled ? HRZones.defaultLowerBounds(maxHR: Double(hrMax)) : [] + } + + /// Move one boundary while preserving strict ordering. Neighbour-aware clamps make it impossible + /// for the stepper to create a gap, overlap, or invalid persisted state. + func stepHRZoneThreshold(at index: Int, up: Bool) { + guard hrZoneThresholds.indices.contains(index) else { return } + var next = hrZoneThresholds + let floor = index == 0 ? HRZones.customBPMRange.lowerBound : next[index - 1] + 1 + let ceiling = index == next.count - 1 ? HRZones.customBPMRange.upperBound : next[index + 1] - 1 + next[index] = min(max(next[index] + (up ? 1 : -1), floor), ceiling) + hrZoneThresholds = next + } + + nonisolated static func validZoneThresholds(_ values: [Int]) -> Bool { + guard values.count == 5, + values.allSatisfy(HRZones.customBPMRange.contains) else { return false } + return zip(values, values.dropFirst()).allSatisfy(<) + } + /// Whether the cycle-awareness opt-in applies to this profile (#801). Cycle phase is read from the /// MENSTRUAL skin-temperature shift, so the opt-in (the Health card + the Automations toggle) is only /// offered to profiles it can apply to and is NOT shown for male profiles. `sex` is the free String diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index ef0430e9e6..55ec0fc251 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -2508,17 +2508,15 @@ final class Repository: ObservableObject { return await hrBuckets(from: from, to: to, bucketSeconds: bucket) } - /// Raw HR samples binned into per-zone MINUTES for a workout window, using the age-derived - /// (Tanaka) %HRmax zones , the same display zone model `WorkoutsView` already uses for imported + /// Raw HR samples binned into per-zone MINUTES for a workout window, using the caller's effective + /// default-or-personalized zones , the same display zone model `WorkoutsView` uses for imported /// zone percentages, but computed here from the strap's own samples so a session WITHOUT imported /// `zonesJSON` still gets a real time-in-zone split. Returns nil when the window carries no HR (so - /// the view shows nothing rather than five empty bars). `age <= 0` falls back to a 30 y default , - /// the zones are approximate either way and clearly labelled as such in the UI. - func workoutZoneMinutes(from: Int, to: Int, age: Int) async -> [Double]? { + /// the view shows nothing rather than five empty bars). + func workoutZoneMinutes(from: Int, to: Int, zoneSet: HRZoneSet) async -> [Double]? { guard to > from else { return nil } let samples = await hrSamples(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/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 2f5a622ca6..a1175c7a84 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -33041,6 +33041,34 @@ } } }, + "Buzz when you hit your top zone (ease off) and again when you recover. Uses your zone thresholds from Settings.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Vibriert, wenn du deine oberste Zone erreichst (runterschalten), und erneut, wenn du dich erholst. Nutzt deine Zonengrenzen aus den Einstellungen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Buzz when you hit your top zone (ease off) and again when you recover. Uses your zone thresholds from Settings." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vibra cuando llegas a tu zona máxima (afloja) y otra vez cuando te recuperas. Usa los límites de zona de Ajustes." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vibre quand vous atteignez votre zone maximale (ralentissez) et de nouveau quand vous récupérez. Utilise vos seuils de zone des Réglages." + } + } + } + }, "Buzz your strap or mark a moment from Siri, Spotlight, the Shortcuts app, or a Back-Tap / automation. No setup needed.": { "localizations": { "de": { @@ -44825,6 +44853,118 @@ } } }, + "Custom HR zones": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Herzfrequenzzonen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom HR zones" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Zonas de FC personalizadas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Zones FC personnalisées" + } + } + } + }, + "Set the BPM where each zone begins. Turn off to restore the default percentage-of-max zones.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lege den BPM-Wert fest, bei dem jede Zone beginnt. Deaktiviere die Option, um die standardmäßigen Prozent-vom-Maximalpuls-Zonen wiederherzustellen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Set the BPM where each zone begins. Turn off to restore the default percentage-of-max zones." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Define las ppm a las que comienza cada zona. Desactiva la opción para restaurar las zonas predeterminadas por porcentaje de frecuencia máxima." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Définissez la fréquence à laquelle chaque zone commence. Désactivez l’option pour restaurer les zones par défaut en pourcentage de la fréquence maximale." + } + } + } + }, + "Zone %lld starts": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zone %lld beginnt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Zone %lld starts" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La zona %lld comienza" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Début de la zone %lld" + } + } + } + }, + "Zone %lld starts at %lld bpm": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zone %1$lld beginnt bei %2$lld bpm" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Zone %1$lld starts at %2$lld bpm" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La zona %1$lld comienza a %2$lld bpm" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La zone %1$lld commence à %2$lld bpm" + } + } + } + }, "Custom %lld ml": { "localizations": { "de": { @@ -135901,6 +136041,34 @@ } } }, + "Time in each heart-rate zone, derived from the strap's heart rate over this window (approximate).": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeit in jeder Herzfrequenzzone, abgeleitet aus der Herzfrequenz des Bands in diesem Zeitraum (Näherungswert)." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Time in each heart-rate zone, derived from the strap's heart rate over this window (approximate)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tiempo en cada zona de frecuencia cardíaca, calculado a partir de la frecuencia de la pulsera durante este intervalo (aproximado)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Temps passé dans chaque zone de fréquence cardiaque, calculé à partir de la fréquence du bracelet sur cet intervalle (approximatif)." + } + } + } + }, "Time of day": { "localizations": { "de": { diff --git a/Strand/Screens/AutomationsView.swift b/Strand/Screens/AutomationsView.swift index 620a2998a6..d8dba64baf 100644 --- a/Strand/Screens/AutomationsView.swift +++ b/Strand/Screens/AutomationsView.swift @@ -176,7 +176,7 @@ struct AutomationsView: View { active: behavior.zoneCoaching || behavior.stressCheckIn) { VStack(spacing: 0) { ToggleRow(label: String(localized: "HR-zone coaching"), - help: String(localized: "Buzz when you hit your top zone (ease off) and again when you recover. Uses your max HR from Settings."), + help: String(localized: "Buzz when you hit your top zone (ease off) and again when you recover. Uses your zone thresholds from Settings."), isOn: $behavior.zoneCoaching) rowDivider // v5 L3 closed-loop check-in (master + sub toggles). Default OFF, manual-first. The keys diff --git a/Strand/Screens/LiveView.swift b/Strand/Screens/LiveView.swift index 14c82ad6db..883b658013 100644 --- a/Strand/Screens/LiveView.swift +++ b/Strand/Screens/LiveView.swift @@ -756,7 +756,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 9a969c608e..c26c794738 100644 --- a/Strand/Screens/LiveWorkoutView.swift +++ b/Strand/Screens/LiveWorkoutView.swift @@ -38,7 +38,7 @@ struct LiveWorkoutView: View { /// used to 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/SettingsView.swift b/Strand/Screens/SettingsView.swift index 5233921146..ee72e27a71 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -406,6 +406,29 @@ struct SettingsView: View { } } rowDivider + FormRow(label: "Custom HR zones") { + Toggle("", isOn: Binding( + get: { profile.hasCustomHRZones }, + set: { profile.setCustomHRZonesEnabled($0) } + )) + .labelsHidden() + .toggleStyle(.switch) + .tint(StrandPalette.accent) + .accessibilityLabel("Custom HR zones") + } + Text("Set the BPM where each zone begins. Turn off to restore the default percentage-of-max zones.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + if profile.hasCustomHRZones { + ForEach(0..<5, id: \.self) { index in + rowDivider + FormRow(label: "Zone \(index + 1) starts") { + hrZoneThresholdField(index: index) + } + } + } + rowDivider // Step calibration (#139/#132): daily steps = @57 counter ticks ÷ this divisor. // 1.0 = raw pass-through until the true 5/MG tick rate is known. The divisor goes // up to 30 because a 5/MG motion counter can overcount by ~24×; the stepper uses a @@ -608,6 +631,29 @@ struct SettingsView: View { } } + /// One personalized inclusive lower boundary. The store owns neighbour-aware clamping so every + /// tap persists a valid, strictly increasing five-zone model. + private func hrZoneThresholdField(index: Int) -> some View { + let value = profile.hrZoneThresholds.indices.contains(index) + ? profile.hrZoneThresholds[index] : 0 + return HStack(spacing: 10) { + Text("\(value)") + .font(StrandFont.bodyNumber) + .foregroundStyle(StrandPalette.textPrimary) + .frame(minWidth: 44, alignment: .trailing) + Text("bpm") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + Stepper("Zone \(index + 1) starts at \(value) bpm") { + profile.stepHRZoneThreshold(at: index, up: true) + } onDecrement: { + profile.stepHRZoneThreshold(at: index, up: false) + } + .labelsHidden() + .accessibilityLabel("Zone \(index + 1) starts at \(value) bpm") + } + } + // MARK: - Units /// Imperial/Metric display toggle + a separate temperature override. Display-only — nothing stored diff --git a/Strand/Screens/WorkoutDetailView.swift b/Strand/Screens/WorkoutDetailView.swift index ba46818fac..b9319f1737 100644 --- a/Strand/Screens/WorkoutDetailView.swift +++ b/Strand/Screens/WorkoutDetailView.swift @@ -123,7 +123,8 @@ 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) } let hrr = await repo.workoutHeartRateRecovery( @@ -428,7 +429,7 @@ struct WorkoutDetailView: View { } Text(zonesFromImport ? "WHOOP's imported per-zone split for this session." - : "Time in each %HRmax zone, derived from the strap's heart rate over this window (approximate).") + : "Time in each heart-rate zone, derived from the strap's heart rate over this window (approximate).") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) } 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 a376d257cb..2fed9763f4 100644 --- a/android/app/src/main/java/com/noop/analytics/HrZones.kt +++ b/android/app/src/main/java/com/noop/analytics/HrZones.kt @@ -40,7 +40,7 @@ data class HrZone( ) /** - * Five HR zones derived from a max HR, plus the max HR itself and its source. + * Five HR zones derived from a max HR or personalized BPM boundaries, plus max HR and its source. * Mirrors Swift `HRZoneSet`. */ data class HrZoneSet( @@ -48,7 +48,7 @@ data class HrZoneSet( val zones: List, /** Max HR (bpm) the zones were built from. */ val maxHR: Double, - /** "tanaka" (age formula) or "manual" (caller override). */ + /** "tanaka" (age formula), "manual" (caller override), or "custom" (personalized boundaries). */ val source: String, ) { /** Return the zone number (1..5) for a bpm value, or 0 when below Zone 1. */ @@ -90,6 +90,9 @@ object HrZones { /** %HRmax band edges for zones 1..5: [0.50, 0.60, 0.70, 0.80, 0.90, 1.00]. */ val zoneEdges: List = listOf(0.50, 0.60, 0.70, 0.80, 0.90, 1.00) + /** Practical editable BPM range used by both platform UIs. */ + val customBPMRange: IntRange = 30..250 + /** Tanaka (2001) age-predicted max HR: 208 − 0.7 × age (gender-independent). */ fun tanakaMaxHR(age: Double): Double = 208.0 - 0.7 * age @@ -99,7 +102,11 @@ object HrZones { * @param age age in years (used only when [maxHROverride] is null). * @param maxHROverride explicit HRmax (bpm); when provided, `source == "manual"`. */ - fun zones(age: Double, maxHROverride: Double? = null): HrZoneSet { + fun zones( + age: Double, + maxHROverride: Double? = null, + customLowerBounds: List? = null, + ): HrZoneSet { val maxHR: Double val source: String if (maxHROverride != null) { @@ -109,26 +116,48 @@ object HrZones { maxHR = tanakaMaxHR(age) source = "tanaka" } - return zones(maxHR, source) + return zones(maxHR, source, customLowerBounds) } - /** Build the 5-zone set directly from a known max HR. */ - fun zones(maxHR: Double, source: String = "manual"): HrZoneSet { + /** + * Build the 5-zone set directly from a known max HR, optionally replacing the conventional + * percentage edges with five personalized inclusive lower bounds in BPM. Invalid custom input + * falls back to the conventional model, so malformed restored preferences cannot create gaps. + */ + fun zones( + maxHR: Double, + source: String = "manual", + customLowerBounds: List? = null, + ): HrZoneSet { + val custom = customLowerBounds?.let(::validCustomLowerBounds) val built = ArrayList(5) for (i in 0 until 5) { - val loPct = zoneEdges[i] - val hiPct = zoneEdges[i + 1] + val lower = custom?.get(i) ?: (zoneEdges[i] * maxHR) + val upper = custom?.let { if (i < 4) it[i + 1] else maxOf(maxHR, it[i]) } + ?: (zoneEdges[i + 1] * maxHR) + val loPct = if (maxHR > 0) lower / maxHR else 0.0 + val hiPct = if (maxHR > 0) upper / maxHR else 0.0 built.add( HrZone( number = i + 1, - lower = loPct * maxHR, - upper = hiPct * maxHR, + lower = lower, + upper = upper, lowerPct = loPct, upperPct = hiPct, ) ) } - return HrZoneSet(zones = built, maxHR = maxHR, source = source) + return HrZoneSet(zones = built, maxHR = maxHR, source = if (custom == null) source else "custom") + } + + /** Conventional inclusive lower bounds, rounded up for a whole-BPM editor. */ + fun defaultLowerBounds(maxHR: Double): List = zoneEdges.take(5).map { kotlin.math.ceil(it * maxHR).toInt() } + + /** Validate the shared five-boundary invariant used by analytics and persistence. */ + fun validCustomLowerBounds(values: List): List? { + if (values.size != 5 || values.any { !it.isFinite() || it <= 0.0 }) return null + if ((1 until values.size).any { values[it] <= values[it - 1] }) return null + return values } /** diff --git a/android/app/src/main/java/com/noop/data/BackupSettings.kt b/android/app/src/main/java/com/noop/data/BackupSettings.kt index 1c53e5a07e..058bf4b226 100644 --- a/android/app/src/main/java/com/noop/data/BackupSettings.kt +++ b/android/app/src/main/java/com/noop/data/BackupSettings.kt @@ -51,6 +51,7 @@ object BackupSettingsCodec { "profile.heightCm" to Kind.DOUBLE, "profile.waistCm" to Kind.DOUBLE, "profile.hrMax" to Kind.INT, + "profile.hrZoneThresholds" to Kind.STRING, "units.system" to Kind.STRING, "units.temperature" to Kind.STRING, "effort.scale" to Kind.STRING, 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 fac3c7d2b8..2965cba1a1 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -1552,17 +1552,16 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { return runCatching { repository.hrBuckets(deviceId, from, to, bucket) }.getOrDefault(emptyList()) } - /** Per-zone MINUTES for a workout window, binning the strap's raw HR samples into the age-derived - * (Tanaka) %HRmax zones — the same display zone model the Workouts screen uses for imported zone - * percentages, but from the strap's own samples so a session WITHOUT imported zones still gets a - * real time-in-zone split. null when the window carries no HR. age <= 0 falls back to 30 y. + /** Per-zone MINUTES for a workout window, binning the strap's raw HR samples into the profile's + * effective default-or-personalized zones — the same display zone model used by live HR and + * imported zone percentages. A session WITHOUT imported zones still gets a real split. null + * when the window carries no HR. * Mirrors macOS Repository.workoutZoneMinutes. */ suspend fun workoutZoneMinutes(from: Long, to: Long): List? { if (to <= from) return null val samples = runCatching { repository.hrSamples(deviceId, 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 @@ -2300,9 +2299,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { if (!_zoneCoaching.value || !state.bonded || !state.worn) return val hr = _bpm.value ?: return if (hr < 30) return - val maxHR = profileStore.hrMax.toDouble() - if (maxHR <= 0) return - val zone = HrZones.zones(maxHR = maxHR).zoneNumber(hr.toDouble()) + if (profileStore.hrMax <= 0) return + val zone = profileStore.hrZoneSet.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 08104e78aa..8b6a11b894 100644 --- a/android/app/src/main/java/com/noop/ui/AutomationsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/AutomationsScreen.kt @@ -83,9 +83,9 @@ fun AutomationsScreen(viewModel: AppViewModel) { val profile = remember { ProfileStore.from(ctx.applicationContext) } val zoneCoaching by viewModel.zoneCoaching.collectAsStateWithLifecycle() 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 + // The effective Zone 5 entry threshold (default or personalized), from the shared model. + val zone5Bpm = remember(profile.hrMax, profile.hrZoneThresholds) { + profile.hrZoneSet.zones.firstOrNull { it.number == 5 }?.lower?.roundToInt() ?: 0 } // Inactivity reminder (#419) — real + persisted via InactivityPrefs (opt-in, default OFF). Seeded @@ -159,7 +159,7 @@ fun AutomationsScreen(viewModel: AppViewModel) { ) { ToggleRow( label = uiString(R.string.l10n_automations_screen_hr_zone_coaching_9306e6e1), - help = "A triple-buzz when you climb into your top zone (Zone 5, ≥ $zone5Bpm bpm), a cue to ease off. Max HR comes from Settings.", + help = uiString(R.string.personalized_hr_zone_coaching_help, zone5Bpm), checked = zoneCoaching, onChange = { viewModel.setZoneCoaching(it) }, ) 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 4f1fe91192..bd470006e0 100644 --- a/android/app/src/main/java/com/noop/ui/LiveScreen.kt +++ b/android/app/src/main/java/com/noop/ui/LiveScreen.kt @@ -137,7 +137,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 693eec1e4a..3126e10c95 100644 --- a/android/app/src/main/java/com/noop/ui/LiveWorkoutScreen.kt +++ b/android/app/src/main/java/com/noop/ui/LiveWorkoutScreen.kt @@ -94,7 +94,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 diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 4719d0709c..cfc61be59e 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -101,6 +101,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.noop.BuildConfig import com.noop.analytics.Baselines +import com.noop.analytics.HrZoneSet +import com.noop.analytics.HrZones import com.noop.analytics.Zones import com.noop.R import com.noop.ble.PuffinExperiment @@ -209,6 +211,23 @@ class ProfileStore(private val prefs: SharedPreferences) { get() = prefs.getInt(KEY_HRMAX, 0).coerceIn(0, 230) set(v) = prefs.edit().putInt(KEY_HRMAX, v.coerceIn(0, 230)).apply() + /** Five personalized inclusive zone starts in BPM; null = conventional %HRmax zones. */ + var hrZoneThresholds: List? + get() { + val values = prefs.getString(KEY_HR_ZONE_THRESHOLDS, null) + ?.split(',') + ?.mapNotNull(String::toIntOrNull) + ?: return null + return values.takeIf(::validZoneThresholds) + } + set(values) { + if (values == null || !validZoneThresholds(values)) { + prefs.edit().remove(KEY_HR_ZONE_THRESHOLDS).apply() + } else { + prefs.edit().putString(KEY_HR_ZONE_THRESHOLDS, values.joinToString(",")).apply() + } + } + /** * Step-calibration divisor (#139/#132): counter ticks per real step for the @57 motion * counter. 1.0 = raw pass-through (default — no behavior change). Clamped 0.5–30.0 @@ -260,6 +279,27 @@ class ProfileStore(private val prefs: SharedPreferences) { /** Effective HR-max: the manual override if set, else the Tanaka estimate. */ val hrMax: Int get() = if (hrMaxOverride > 0) hrMaxOverride else hrMaxAuto + /** Shared personalized/default zone set for live HR, workout splits, and haptic coaching. */ + val hrZoneSet: HrZoneSet + get() = HrZones.zones( + maxHR = hrMax.toDouble(), + customLowerBounds = hrZoneThresholds?.map(Int::toDouble), + ) + + fun setCustomHrZonesEnabled(enabled: Boolean) { + hrZoneThresholds = if (enabled) HrZones.defaultLowerBounds(hrMax.toDouble()) else null + } + + /** Move one boundary while preserving strict ordering and the practical editable range. */ + fun stepHrZoneThreshold(index: Int, up: Boolean) { + val current = hrZoneThresholds?.toMutableList() ?: return + if (index !in current.indices) return + val floor = if (index == 0) HrZones.customBPMRange.first else current[index - 1] + 1 + val ceiling = if (index == current.lastIndex) HrZones.customBPMRange.last else current[index + 1] - 1 + current[index] = (current[index] + if (up) 1 else -1).coerceIn(floor, ceiling) + hrZoneThresholds = current + } + // ── Backup settings snapshot/apply (#1000) ────────────────────────────────────────────────── // The profile half of a `.noopbak`'s `settings.json`. Canonical key strings mirror // `BackupSettingsCodec.WHITELIST` (and the Apple `BackupSettings.whitelist`) exactly — note @@ -279,6 +319,9 @@ class ProfileStore(private val prefs: SharedPreferences) { if (prefs.contains(KEY_HEIGHT)) out["profile.heightCm"] = heightCm if (prefs.contains(KEY_WAIST)) out["profile.waistCm"] = waistCm if (prefs.contains(KEY_HRMAX)) out["profile.hrMax"] = hrMaxOverride + if (prefs.contains(KEY_HR_ZONE_THRESHOLDS)) { + hrZoneThresholds?.let { out["profile.hrZoneThresholds"] = it.joinToString(",") } + } return out } @@ -297,6 +340,9 @@ class ProfileStore(private val prefs: SharedPreferences) { (values["profile.heightCm"] as? Number)?.let { heightCm = it.toDouble() } (values["profile.waistCm"] as? Number)?.let { waistCm = it.toDouble() } (values["profile.hrMax"] as? Number)?.let { hrMaxOverride = it.toInt() } + (values["profile.hrZoneThresholds"] as? String)?.let { encoded -> + hrZoneThresholds = encoded.split(',').mapNotNull(String::toIntOrNull) + } } companion object { @@ -311,6 +357,7 @@ class ProfileStore(private val prefs: SharedPreferences) { private const val KEY_HEIGHT = "height_cm" private const val KEY_WAIST = "waist_cm" private const val KEY_HRMAX = "hr_max_override" + private const val KEY_HR_ZONE_THRESHOLDS = "hr_zone_thresholds" private const val KEY_STEP_SCALE = "step_ticks_per_step" private const val KEY_STEPS_COEFF = "steps_calibration_coefficient" private const val KEY_STEPS_SAMPLE_DAYS = "steps_calibration_sample_days" @@ -328,6 +375,11 @@ class ProfileStore(private val prefs: SharedPreferences) { private const val STEP_SCALE_MIN = 0.5 private const val STEP_SCALE_MAX = 30.0 + fun validZoneThresholds(values: List): Boolean = + values.size == 5 && + values.all { it in HrZones.customBPMRange } && + values.zipWithNext().all { (a, b) -> a < b } + /** * Variable step for the calibration stepper so high values stay reachable: fine near the * 1.0 default (where most people land), coarse up at the 20s+ a 5/MG needs. A flat 0.1 step @@ -842,6 +894,44 @@ fun SettingsScreen( } } RowDivider() + FormRow(label = uiString(R.string.personalized_hr_zones)) { + Switch( + checked = profile.hrZoneThresholds != null, + onCheckedChange = { enabled -> mutate { profile.setCustomHrZonesEnabled(enabled) } }, + colors = SwitchDefaults.colors( + checkedThumbColor = Palette.surfaceBase, + checkedTrackColor = Palette.accent, + uncheckedThumbColor = Palette.textTertiary, + uncheckedTrackColor = Palette.surfaceOverlay, + uncheckedBorderColor = Palette.hairline, + ), + modifier = Modifier.semantics { + contentDescription = uiString(R.string.personalized_hr_zones) + }, + ) + } + Text( + uiString(R.string.personalized_hr_zones_help), + style = NoopType.footnote, + color = Palette.textTertiary, + ) + profile.hrZoneThresholds?.forEachIndexed { index, value -> + RowDivider() + FormRow(label = uiString(R.string.personalized_hr_zone_starts, index + 1)) { + StepperField( + value = value.toString(), + unit = "bpm", + accessibility = uiString( + R.string.personalized_hr_zone_starts_at_bpm, + index + 1, + value, + ), + onMinus = { mutate { profile.stepHrZoneThreshold(index, up = false) } }, + onPlus = { mutate { profile.stepHrZoneThreshold(index, up = true) } }, + ) + } + } + RowDivider() // Step calibration (#139/#132): daily steps = @57 counter ticks ÷ this divisor. // 1.0 = raw pass-through until the true 5/MG tick rate is known. The divisor goes // up to 30 because a 5/MG motion counter can overcount by ~24×; the stepper uses a diff --git a/android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt b/android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt index 2919a1e5fd..00dfc5f3c7 100644 --- a/android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt @@ -1421,7 +1421,7 @@ private fun WorkoutDetailSheet(vm: AppViewModel, row: WorkoutRow, onDismiss: () } Text( if (zonesFromImport) "WHOOP's imported per-zone split for this session." - else "Time in each %HRmax zone, derived from the strap's heart rate over this window (approximate).", + else uiString(R.string.personalized_hr_zone_time_help), style = NoopType.footnote, color = Palette.textTertiary, ) diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index b5e794f73b..68df1a03db 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1665,6 +1665,12 @@ Wie erholt du bist, angeführt von der HRV gegenüber deiner persönlichen Basislinie. Herz-Kreislauf-Belastung für den Tag auf einer Skala von 0–100 (zuvor 0–21). Wie erholsam dein Schlaf war: Dauer, Effizienz, Tief- und REM-Schlaf sowie Timing. + Benutzerdefinierte Herzfrequenzzonen + Lege den BPM-Wert fest, bei dem jede Zone beginnt. Deaktiviere die Option, um die standardmäßigen Prozent-vom-Maximalpuls-Zonen wiederherzustellen. + Zone %1$d beginnt + Zone %1$d beginnt bei %2$d bpm + Dreifaches Vibrieren beim Erreichen deiner höchsten Zone (Zone 5, ≥ %1$d bpm) als Hinweis, das Tempo zu reduzieren. Die Zonengrenzen kommen aus den Einstellungen. + Zeit in jeder Herzfrequenzzone, abgeleitet aus der Herzfrequenz des Bands in diesem Zeitraum (Näherungswert). Herzfrequenzerholung Nach hochintensiver Belastung Spitze %1$d bpm diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index ccc1ac2b04..afb202f192 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1650,6 +1650,12 @@ Qué tan recuperado estás, según tu VFC frente a tu línea base personal. Carga cardiovascular del día, en una escala de 0–100 (antes 0–21). Qué tan reparador fue tu sueño: duración, eficiencia, sueño profundo y REM, y horario. + Zonas de FC personalizadas + Define las ppm a las que comienza cada zona. Desactiva la opción para restaurar las zonas predeterminadas por porcentaje de frecuencia máxima. + La zona %1$d comienza + La zona %1$d comienza a %2$d bpm + Tres vibraciones al entrar en tu zona más alta (zona 5, ≥ %1$d bpm), como señal para bajar el ritmo. Los límites de zona se configuran en Ajustes. + Tiempo en cada zona de frecuencia cardíaca, calculado a partir de la frecuencia del sensor durante este intervalo (aproximado). Recuperación de la frecuencia cardíaca Después de un esfuerzo de alta intensidad Pico de %1$d lpm diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 987e7d5d01..4d86b890e2 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1650,6 +1650,12 @@ Votre niveau de récupération, mesuré par la VFC par rapport à votre référence personnelle. Charge cardiovasculaire de la journée, sur une échelle de 0 à 100 (auparavant 0 à 21). À quel point votre sommeil a été réparateur : durée, efficacité, sommeil profond et paradoxal, et horaires. + Zones FC personnalisées + Définissez la fréquence à laquelle chaque zone commence. Désactivez l’option pour restaurer les zones par défaut en pourcentage de la fréquence maximale. + Début de la zone %1$d + La zone %1$d commence à %2$d bpm + Trois vibrations lorsque vous entrez dans votre zone la plus élevée (zone 5, ≥ %1$d bpm), pour vous inviter à ralentir. Les seuils de zone viennent des Réglages. + Temps passé dans chaque zone de fréquence cardiaque, calculé à partir de la fréquence du bracelet sur cet intervalle (approximatif). Récupération de la fréquence cardiaque Après un effort intense Pic à %1$d bpm diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4e5bd7257c..f2b0bfd693 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1674,6 +1674,12 @@ How recovered you are, led by HRV versus your personal baseline. Cardiovascular load for the day, on a 0-100 scale (was 0-21). How restorative your sleep was: duration, efficiency, deep+REM, timing. + Custom HR zones + Set the BPM where each zone begins. Turn off to restore the default percentage-of-max zones. + Zone %1$d starts + Zone %1$d starts at %2$d bpm + A triple-buzz when you climb into your top zone (Zone 5, ≥ %1$d bpm), a cue to ease off. Zone thresholds come from Settings. + Time in each heart-rate zone, derived from the strap\'s heart rate over this window (approximate). Heart Rate Recovery After high-intensity effort Peak %1$d bpm diff --git a/android/app/src/test/java/com/noop/analytics/HrZonesTest.kt b/android/app/src/test/java/com/noop/analytics/HrZonesTest.kt index bc78e04f26..0cdfa16021 100644 --- a/android/app/src/test/java/com/noop/analytics/HrZonesTest.kt +++ b/android/app/src/test/java/com/noop/analytics/HrZonesTest.kt @@ -11,6 +11,36 @@ import org.junit.Test */ class HrZonesTest { + @Test + fun customBpmBoundariesReplacePercentageEdges() { + val zs = HrZones.zones( + maxHR = 200.0, + customLowerBounds = listOf(95.0, 118.0, 142.0, 168.0, 184.0), + ) + assertEquals("custom", zs.source) + assertEquals(listOf(95.0, 118.0, 142.0, 168.0, 184.0), zs.zones.map { it.lower }) + assertEquals(1, zs.zoneNumber(117.0)) + assertEquals(2, zs.zoneNumber(118.0)) + assertEquals(4, zs.zoneNumber(168.0)) + assertEquals(5, zs.zoneNumber(184.0)) + assertEquals(5, zs.zoneNumber(230.0)) + } + + @Test + fun invalidCustomBoundariesFallBackToDefaults() { + val zs = HrZones.zones( + maxHR = 200.0, + customLowerBounds = listOf(100.0, 120.0, 120.0, 160.0, 180.0), + ) + assertEquals("manual", zs.source) + assertEquals(listOf(100.0, 120.0, 140.0, 160.0, 180.0), zs.zones.map { it.lower }) + } + + @Test + fun defaultEditorBoundsPreserveIntegerClassification() { + assertEquals(listOf(94, 113, 131, 150, 169), HrZones.defaultLowerBounds(187.0)) + } + @Test fun timeInZone_capsHugePositiveGap() { // Regression (#366): three 1 Hz zone-1 samples (median gap 1 s), then one sample an HOUR diff --git a/android/app/src/test/java/com/noop/data/BackupSettingsCodecTest.kt b/android/app/src/test/java/com/noop/data/BackupSettingsCodecTest.kt index ab75f77649..ac982e2fa3 100644 --- a/android/app/src/test/java/com/noop/data/BackupSettingsCodecTest.kt +++ b/android/app/src/test/java/com/noop/data/BackupSettingsCodecTest.kt @@ -35,6 +35,7 @@ class BackupSettingsCodecTest { "profile.heightCm" to 168.0, "profile.waistCm" to 71.0, "profile.hrMax" to 191, + "profile.hrZoneThresholds" to "95,118,142,168,184", "units.system" to "imperial", "units.temperature" to "celsius", "effort.scale" to "whoop", @@ -48,6 +49,7 @@ class BackupSettingsCodecTest { assertEquals(168.0, back["profile.heightCm"]) assertEquals(71.0, back["profile.waistCm"]) assertEquals(191, back["profile.hrMax"]) + assertEquals("95,118,142,168,184", back["profile.hrZoneThresholds"]) assertEquals("imperial", back["units.system"]) assertEquals("celsius", back["units.temperature"]) assertEquals("whoop", back["effort.scale"])