Skip to content
Closed
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
22 changes: 22 additions & 0 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Strand/Data/BehaviorStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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() {
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions Strand/Screens/AutomationsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
63 changes: 63 additions & 0 deletions Strand/System/StrainGoalNotifier.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
}