From 0230091cc6c0f11fce1eb2a4d2d6d943963a39a6 Mon Sep 17 00:00:00 2001 From: tigercraft4 <> Date: Sun, 19 Jul 2026 02:56:44 +0100 Subject: [PATCH] feat(ios): add Target Strain Reached notification (#593) WHOOP's official app fires a "Target Strain Reached" celebratory notification; NOOP had wind-down, battery, illness, inactivity, and scheduled report notifications but nothing for this. HONEST about scope: the official copy implies a per-activity target from an undocumented proprietary model NOOP has no visibility into. This instead fires against the day's recovery-based optimal-Strain floor (CoupledView.optimalStrainRange(recovery:).lowerBound) -- the one target NOOP can actually compute -- and the notification copy says "today", not "for this activity", so it never overclaims precision NOOP doesn't have. - StrainGoalNotifier (new): pure once-per-day crossing policy (mirrors BatteryAlertPolicy) + a point-in-time post gated on live authorization status, same pattern as BatteryNotifier/IllnessNotifier. - AppModel: evaluateStrainGoal(_:) hooked into the same repo.$days sink as evaluateIllness; uses Repository.widgetAnchor to find the row Today displays. - BehaviorStore: strainGoalAlerts toggle, default OFF (opt-in like every automation here). - AutomationsView: new "Strain goal" card, mirrors the Illness card's request-authorization-on-enable pattern. iOS only for now -- Android is a tracked fast-follow (#593). --- Strand/App/AppModel.swift | 22 +++++++++ Strand/Data/BehaviorStore.swift | 7 +++ Strand/Screens/AutomationsView.swift | 17 +++++++ Strand/System/StrainGoalNotifier.swift | 63 ++++++++++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 Strand/System/StrainGoalNotifier.swift diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 3d048a230a..8b904d6f07 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -265,6 +265,9 @@ final class AppModel: ObservableObject { $bpm.sink { [weak self] hr in self?.coachZone(hr) }.store(in: &hrCancellables) // Illness/strain early-warning recomputes when the daily history changes. repo.$days.sink { [weak self] days in self?.evaluateIllness(days) }.store(in: &hrCancellables) + // Target Strain Reached (#593): re-check today's row against the recovery-based optimal-Strain + // floor whenever the daily history changes (a strain re-score, not just a fresh day). + repo.$days.sink { [weak self] days in self?.evaluateStrainGoal(days) }.store(in: &hrCancellables) // Re-arm the strap's firmware alarm once the connection has SETTLED — not the instant it (re)bonds. // A smart-alarm time changed while the strap was away never reached it , the send is gated on bond // , so the strap kept the OLD time and fired at it (#59). @@ -1332,6 +1335,18 @@ final class AppModel: ObservableObject { /// (alcohol / a hard-or-late workout / etc.) so a night out doesn't cry wolf. The journal context is /// read asynchronously, so this kicks a Task; the published `illnessSignal` + the `healthAlert` /// banner both come from the engine's single decision. On-device only, APPROXIMATE , not a diagnosis. + /// Target Strain Reached (#593): fire once per day when today's Strain first reaches the + /// recovery-based optimal-Strain floor (`optimalStrainRange`) — the one "target" NOOP can honestly + /// compute (the official app's undocumented per-activity target isn't something NOOP has visibility + /// into; the notification copy says "today", not "for this activity"). Uses `Repository.widgetAnchor` + /// to find the row Today actually displays, same as the Home/Lock widget and Live Activity. + private func evaluateStrainGoal(_ days: [DailyMetric]) { + guard let today = Repository.widgetAnchor(days: days) else { return } + let target = CoupledView.optimalStrainRange(recovery: today.recovery).map { Double($0.lowerBound) } + StrainGoalNotifier.onStrainUpdate(day: today.day, strain: today.strain, targetStrain: target, + enabled: behavior.strainGoalAlerts) + } + private func evaluateIllness(_ days: [DailyMetric]) { guard behavior.illnessWatch, days.count >= 14 else { healthAlert = nil; illnessSignal = nil; illnessDistance = nil; return @@ -1445,6 +1460,13 @@ final class AppModel: ObservableObject { evaluateIllness(repo.days) } + /// Re-run the strain-goal check over the cached history. Called when the Automations toggle flips, + /// the repo.$days sink only fires on data changes, so a flip would otherwise wait for the next + /// refresh (#593). + func reevaluateStrainGoal() { + evaluateStrainGoal(repo.days) + } + // MARK: - v5 skin-temp suite engines (cycle phase + body clock) // // Run in the analytics pass (IntelligenceEngine calls this after it persists the night's scores) so diff --git a/Strand/Data/BehaviorStore.swift b/Strand/Data/BehaviorStore.swift index 5bc5c683e8..9b1dfb0a89 100644 --- a/Strand/Data/BehaviorStore.swift +++ b/Strand/Data/BehaviorStore.swift @@ -52,6 +52,11 @@ final class BehaviorStore: ObservableObject { /// batteryAlerts (both must be on). Default ON so pre-toggle behavior is unchanged. @Published var batteryPredictiveAlerts: Bool { didSet { d.set(batteryPredictiveAlerts, forKey: K.batteryPredictiveAlerts) } } + // MARK: Strain goal + /// Notify once per day when today's Strain first reaches the recovery-based optimal-Strain floor + /// (#593). Default OFF — opt-in like every other automation here. + @Published var strainGoalAlerts: Bool { didSet { d.set(strainGoalAlerts, forKey: K.strainGoalAlerts) } } + private let d = UserDefaults.standard private enum K { static let dtAction = "behavior.doubleTapAction" @@ -74,6 +79,7 @@ final class BehaviorStore: ObservableObject { static let illness = "behavior.illnessWatch" static let batteryAlerts = "behavior.batteryAlerts" static let batteryPredictiveAlerts = "behavior.batteryPredictiveAlerts" + static let strainGoalAlerts = "behavior.strainGoalAlerts" } init() { @@ -95,6 +101,7 @@ final class BehaviorStore: ObservableObject { illnessWatch = d.object(forKey: K.illness) as? Bool ?? false batteryAlerts = d.object(forKey: K.batteryAlerts) as? Bool ?? true batteryPredictiveAlerts = d.object(forKey: K.batteryPredictiveAlerts) as? Bool ?? true + strainGoalAlerts = d.object(forKey: K.strainGoalAlerts) as? Bool ?? false } // MARK: Charge baseline recalibration diff --git a/Strand/Screens/AutomationsView.swift b/Strand/Screens/AutomationsView.swift index f2426eb5f7..74639da23a 100644 --- a/Strand/Screens/AutomationsView.swift +++ b/Strand/Screens/AutomationsView.swift @@ -53,6 +53,7 @@ struct AutomationsView: View { // wake/wind-down control lives in one place. Automations is just inputs-to-actions now. inactivityCard illnessCard + strainGoalCard healthInsightsCard batteryCard } @@ -300,6 +301,22 @@ struct AutomationsView: View { } } + // MARK: - Strain goal + + private var strainGoalCard: some View { + Section2(icon: "bolt.heart", title: String(localized: "Strain goal"), + blurb: String(localized: "Notifies you once a day when your Strain first reaches your recovery-based optimal-Strain target."), + active: behavior.strainGoalAlerts) { + ToggleRow(label: String(localized: "Target Strain Reached"), + help: String(localized: "Fires at most once a day, the moment your Strain reaches the low end of today's optimal range for your Recovery."), + isOn: $behavior.strainGoalAlerts) + .onChangeCompat(of: behavior.strainGoalAlerts) { _ in + model.reevaluateStrainGoal() + if behavior.strainGoalAlerts { StrainGoalNotifier.requestAuthorization() } + } + } + } + // MARK: - Health insights (v5: cycle awareness opt-in · experimental Rhythm) private var healthInsightsCard: some View { diff --git a/Strand/System/StrainGoalNotifier.swift b/Strand/System/StrainGoalNotifier.swift new file mode 100644 index 0000000000..40999156cf --- /dev/null +++ b/Strand/System/StrainGoalNotifier.swift @@ -0,0 +1,63 @@ +import Foundation +import UserNotifications + +/// Surfaces a "Target Strain Reached" notification once per day — the WHOOP-native celebratory nudge +/// (#593) NOOP was missing. Mirrors `BatteryNotifier`/`IllnessNotifier`: point-in-time post gated on +/// notification-authorization status at fire time, gated behind the user's "Strain goal" setting +/// (default OFF, opt-in like every automation here). On-device only. +/// +/// HONEST about scope: the official app's copy ("...for this activity!") implies a per-activity target +/// from an undocumented proprietary model. NOOP has no visibility into that. Instead this fires against +/// the day's recovery-based OPTIMAL STRAIN floor (`AppModel.optimalStrainRange(recovery:).lowerBound`) — +/// the one target NOOP can actually compute, and the copy below says "today", not "for this activity", +/// so it never claims more precision than NOOP has. +enum StrainGoalNotifier { + private static let alertedDayKey = "behavior.strainGoalAlertedDay" + + /// Pure crossing policy: fires at most once per calendar day, the moment `strain` first reaches + /// `targetStrain`. `alertedDay` is the last day a fire was recorded (nil if never); comparing against + /// `day` (not a boolean) means a new day always re-arms without needing an explicit midnight reset. + enum StrainGoalPolicy { + static func evaluate(day: String, strain: Double?, targetStrain: Double?, alertedDay: String?) + -> (fire: Bool, newAlertedDay: String?) { + guard let strain, let targetStrain, strain >= targetStrain, alertedDay != day else { + return (false, alertedDay) + } + return (true, day) + } + } + + static func requestAuthorization() { + UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + + /// Evaluate today's row and fire if the policy says to. `enabled` is the caller-owned + /// `BehaviorStore.strainGoalAlerts` toggle, so a disabled automation never persists a crossing (a + /// later enable re-arms honestly against that day's real state, not a stale flag from while it was + /// off). + static func onStrainUpdate(day: String, strain: Double?, targetStrain: Double?, enabled: Bool) { + guard enabled else { return } + let d = UserDefaults.standard + let alertedDay = d.string(forKey: alertedDayKey) + let decision = StrainGoalPolicy.evaluate(day: day, strain: strain, targetStrain: targetStrain, + alertedDay: alertedDay) + guard decision.fire else { return } + d.set(decision.newAlertedDay, forKey: alertedDayKey) + post(day: day, strain: strain ?? 0) + } + + private static func post(day: String, strain: Double) { + let center = UNUserNotificationCenter.current() + // Authorization is requested once via requestAuthorization() when the toggle is enabled; here + // we only check status so a revoked permission can't queue an undeliverable notification. + center.getNotificationSettings { settings in + guard settings.authorizationStatus == .authorized else { return } + let content = UNMutableNotificationContent() + content.title = String(localized: "Target Strain Reached") + content.body = String(format: String(localized: "You've hit your target Strain of %.1f for today!"), strain) + content.sound = .default + center.add(UNNotificationRequest(identifier: "strain-goal-\(day)", content: content, trigger: nil)) + } + } +}