Skip to content
Open
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
3 changes: 2 additions & 1 deletion Strand/AI/AICoach.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ final class AICoachEngine: ObservableObject {
/// Kotlin `CoachViewModel.keyRejected`.
@Published var keyRejected = false

/// #1862: a question handed over by the Today Coach launcher sheet, for `CoachView` to send on appear.
/// #1862/#1736: a question handed over by the Today Coach launcher sheet for `CoachView` to place
/// in its composer. The user reviews and explicitly sends it there.
///
/// The launcher owns no send, stream, error or consent surface of its own — duplicating those is how a
/// second chat UI drifts from the first. It collects a question and hands it here; the Coach screen,
Expand Down
10 changes: 10 additions & 0 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,16 @@ final class AppModel: ObservableObject {
repo.$days.sink { [weak self] days in
self?.evaluateIllness(days)
self?.evaluateStrainTarget()
if let self, let row = days.last(where: { $0.totalSleepMin != nil }) {
DailyCoachNotifier.onMorning(
day: row.day,
recovery: row.recovery,
rest: AnalyticsEngine.Rest.composite(daily: row),
hrv: row.avgHrv,
restingHR: row.restingHr,
sleepMinutes: row.totalSleepMin,
enabled: self.behavior.dailyCoach)
}
// Keep the battery night-guard's learned bedtime warm off the same signal (throttled inside).
self?.refreshHabitualMidsleep()
}.store(in: &hrCancellables)
Expand Down
4 changes: 4 additions & 0 deletions Strand/Data/BehaviorStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ final class BehaviorStore: ObservableObject {
/// Once-a-day "optimal strain reached" nudge when the day's Effort hits the low end of today's
/// recovery-derived optimal band. Default OFF like every other automation.
@Published var strainTargetNudge: Bool { didSet { d.set(strainTargetNudge, forKey: K.strainTargetNudge) } }
/// One local post-sync morning briefing. Default OFF; it never contacts an AI provider.
@Published var dailyCoach: Bool { didSet { d.set(dailyCoach, forKey: K.dailyCoach) } }

private let d = UserDefaults.standard
private enum K {
Expand All @@ -80,6 +82,7 @@ final class BehaviorStore: ObservableObject {
static let batteryAlerts = "behavior.batteryAlerts"
static let batteryPredictiveAlerts = "behavior.batteryPredictiveAlerts"
static let strainTargetNudge = "behavior.strainTargetNudge"
static let dailyCoach = "behavior.dailyCoach"
}

init() {
Expand All @@ -102,6 +105,7 @@ final class BehaviorStore: ObservableObject {
batteryAlerts = d.object(forKey: K.batteryAlerts) as? Bool ?? true
batteryPredictiveAlerts = d.object(forKey: K.batteryPredictiveAlerts) as? Bool ?? true
strainTargetNudge = d.object(forKey: K.strainTargetNudge) as? Bool ?? false
dailyCoach = d.object(forKey: K.dailyCoach) as? Bool ?? false
}

// MARK: Charge baseline recalibration
Expand Down
14 changes: 14 additions & 0 deletions Strand/Screens/AutomationsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ struct AutomationsView: View {
healthInsightsCard
batteryCard
strainTargetCard
dailyCoachCard
}
}

Expand Down Expand Up @@ -441,6 +442,19 @@ struct AutomationsView: View {
}
}

private var dailyCoachCard: some View {
Section2(icon: "sparkles", title: String(localized: "Morning brief"),
blurb: String(localized: "A local notification with today's readiness + training plan, generated on-device each morning."),
active: behavior.dailyCoach) {
ToggleRow(label: String(localized: "Morning brief"),
help: String(localized: "All stays on \(Platform.deviceNounPhrase). Nothing is sent anywhere."),
isOn: $behavior.dailyCoach)
.onChangeCompat(of: behavior.dailyCoach) { on in
if on { DailyCoachNotifier.requestAuthorization() }
}
}
}

// MARK: - Helpers

/// Double-tap actions offered in the picker. The "Lock the Mac" action can't work on iPhone
Expand Down
21 changes: 11 additions & 10 deletions Strand/Screens/CoachView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,10 @@ struct CoachView: View {
CoachBriefScheduler.activateIfEnabled { await coach.generateBrief() }
await coach.startBriefIfNeeded()
}
// #1862: a question handed over by the Today launcher sheet. Cleared BEFORE sending so a view
// rebuild mid-flight cannot send it twice, and gated on `isConfigured` so an unconfigured handoff
// (which the launcher does not produce, but a future caller might) degrades to showing setup
// rather than a failed request.
.task(id: coach.pendingPrompt) {
guard let prompt = coach.pendingPrompt, !prompt.isEmpty else { return }
coach.pendingPrompt = nil
guard coach.isConfigured else { return }
await coach.send(prompt)
}
// #1736: a question handed over by the Today launcher is only prepared as a draft. The user
// gets a final review/edit step in the full Coach screen before any provider request is made.
.onAppear { takePendingPrompt() }
.onChangeCompat(of: coach.pendingPrompt) { _ in takePendingPrompt() }
// K15: persist the composer draft so it survives an app relaunch.
.onChangeCompat(of: draft) { newValue in
UserDefaults.standard.set(newValue, forKey: Self.draftKey)
Expand Down Expand Up @@ -1133,6 +1127,13 @@ struct CoachView: View {
Task { await coach.send(trimmed) }
}

private func takePendingPrompt() {
guard let prompt = coach.pendingPrompt, !prompt.isEmpty else { return }
draft = prompt
coach.pendingPrompt = nil
composerFocused = true
}

/// K14: Trigger a subtle haptic when the Coach reply arrives. On iOS, a light impact feedback.
/// macOS doesn't have an equivalent simple API, so it's a no-op there.
private func triggerReplyHaptic() {
Expand Down
85 changes: 85 additions & 0 deletions Strand/System/DailyCoachNotifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Foundation
import UserNotifications

/// A local, opt-in morning briefing. It uses only already-scored on-device values and never calls a
/// provider; a missing metric is omitted instead of guessed.
enum DailyCoachNotifier {
enum TrainingBand: Equatable {
case recovery, controlled, harder
}

struct Brief: Equatable {
let charge: Int?
let rest: Int?
let hrvMs: Int?
let restingHR: Int?
let sleepHours: Int?
let trainingBand: TrainingBand?
}

private static let lastDayKey = "behavior.dailyCoachLastDay"

static func requestAuthorization() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
}

static func makeBrief(recovery: Double?, rest: Double?, hrv: Double?, restingHR: Int?,
sleepMinutes: Double?) -> Brief? {
let charge = recovery.map { Int($0.rounded()) }
let roundedRest = rest.map { Int($0.rounded()) }
let hrvMs = hrv.map { Int($0.rounded()) }
let sleepHours = sleepMinutes.map { Int(($0 / 60).rounded()) }
guard charge != nil || roundedRest != nil || hrvMs != nil || restingHR != nil || sleepHours != nil else {
return nil
}
let trainingBand = charge.map {
switch $0 {
case 67...: return TrainingBand.harder
case 34..<67: return TrainingBand.controlled
default: return TrainingBand.recovery
}
}
return Brief(charge: charge, rest: roundedRest, hrvMs: hrvMs, restingHR: restingHR,
sleepHours: sleepHours, trainingBand: trainingBand)
}

static func onMorning(day: String, recovery: Double?, rest: Double?, hrv: Double?,
restingHR: Int?, sleepMinutes: Double?, enabled: Bool) {
let d = UserDefaults.standard
guard enabled, d.string(forKey: lastDayKey) != day,
let brief = makeBrief(recovery: recovery, rest: rest, hrv: hrv,
restingHR: restingHR, sleepMinutes: sleepMinutes) else { return }
var parts: [String] = []
if let charge = brief.charge { parts.append("\(String(localized: "Charge")) \(charge)") }
if let rest = brief.rest { parts.append("\(String(localized: "Rest")) \(rest)") }
if let hrv = brief.hrvMs { parts.append("\(String(localized: "HRV")) \(hrv) ms") }
if let restingHR = brief.restingHR { parts.append("\(String(localized: "RHR")) \(restingHR) bpm") }
if let sleepHours = brief.sleepHours { parts.append("\(String(localized: "Sleep")) \(sleepHours) h") }
let training: String?
switch brief.trainingBand {
case .some(.harder):
training = String(localized: "Your signals are aligned and your load is supported. A harder session is well backed today.")
case .some(.controlled):
training = String(localized: "One of your signals is flagging. You can train, but keep it controlled and bank the recovery.")
case .some(.recovery):
training = String(localized: "Several signals are down at once. Treat today as recovery - easy movement, real sleep tonight.")
case .none:
training = nil
}
UNUserNotificationCenter.current().getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized else { return }
let content = UNMutableNotificationContent()
content.title = String(localized: "Morning brief")
content.body = parts.joined(separator: " · ") + (training.map { ". \($0)" } ?? "")
content.sound = .default
centerAdd(content, day: day)
}
}

private static func centerAdd(_ content: UNMutableNotificationContent, day: String) {
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: "daily-coach", content: content, trigger: nil)) { error in
guard error == nil else { return }
UserDefaults.standard.set(day, forKey: lastDayKey)
}
}
}
65 changes: 65 additions & 0 deletions StrandTests/DailyCoachNotifierTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import XCTest
@testable import Strand

final class DailyCoachNotifierTests: XCTestCase {
func testBriefIncludesEveryAvailableMetric() throws {
let brief = try XCTUnwrap(DailyCoachNotifier.makeBrief(
recovery: 72.2,
rest: 88.4,
hrv: 61.6,
restingHR: 52,
sleepMinutes: 479
))

XCTAssertEqual(brief.charge, 72)
XCTAssertEqual(brief.rest, 88)
XCTAssertEqual(brief.hrvMs, 62)
XCTAssertEqual(brief.restingHR, 52)
XCTAssertEqual(brief.sleepHours, 8)
}

func testDisplayedRoundedChargeAlsoDrivesBanding() throws {
let high = try XCTUnwrap(DailyCoachNotifier.makeBrief(
recovery: 66.6, rest: nil, hrv: nil, restingHR: nil, sleepMinutes: nil
))
let controlled = try XCTUnwrap(DailyCoachNotifier.makeBrief(
recovery: 66.4, rest: nil, hrv: nil, restingHR: nil, sleepMinutes: nil
))

XCTAssertEqual(high.charge, 67)
XCTAssertEqual(high.trainingBand, .harder)
XCTAssertEqual(controlled.charge, 66)
XCTAssertEqual(controlled.trainingBand, .controlled)
}

func testBothBandBoundaries() throws {
let recovery = try XCTUnwrap(DailyCoachNotifier.makeBrief(
recovery: 33.4, rest: nil, hrv: nil, restingHR: nil, sleepMinutes: nil
))
let controlled = try XCTUnwrap(DailyCoachNotifier.makeBrief(
recovery: 33.5, rest: nil, hrv: nil, restingHR: nil, sleepMinutes: nil
))
let harder = try XCTUnwrap(DailyCoachNotifier.makeBrief(
recovery: 66.5, rest: nil, hrv: nil, restingHR: nil, sleepMinutes: nil
))

XCTAssertEqual(recovery.trainingBand, .recovery)
XCTAssertEqual(controlled.trainingBand, .controlled)
XCTAssertEqual(harder.trainingBand, .harder)
}

func testMissingChargeNeverInventsTrainingAdvice() throws {
let brief = try XCTUnwrap(DailyCoachNotifier.makeBrief(
recovery: nil, rest: nil, hrv: 58, restingHR: nil, sleepMinutes: nil
))

XCTAssertNil(brief.charge)
XCTAssertNil(brief.trainingBand)
}

func testAllMetricsMissingReturnsNil() {
XCTAssertNil(DailyCoachNotifier.makeBrief(
recovery: nil, rest: nil, hrv: nil, restingHR: nil, sleepMinutes: nil
))
}
}
Loading