diff --git a/Strand/AI/AICoach.swift b/Strand/AI/AICoach.swift index 7031d5ac1d..8a682e0fef 100644 --- a/Strand/AI/AICoach.swift +++ b/Strand/AI/AICoach.swift @@ -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, diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index c8c533b22c..7d854fa9c6 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -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) diff --git a/Strand/Data/BehaviorStore.swift b/Strand/Data/BehaviorStore.swift index 5001267b5f..d1c152549a 100644 --- a/Strand/Data/BehaviorStore.swift +++ b/Strand/Data/BehaviorStore.swift @@ -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 { @@ -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() { @@ -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 diff --git a/Strand/Screens/AutomationsView.swift b/Strand/Screens/AutomationsView.swift index 3cc32dabde..95b42e4b37 100644 --- a/Strand/Screens/AutomationsView.swift +++ b/Strand/Screens/AutomationsView.swift @@ -69,6 +69,7 @@ struct AutomationsView: View { healthInsightsCard batteryCard strainTargetCard + dailyCoachCard } } @@ -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 diff --git a/Strand/Screens/CoachView.swift b/Strand/Screens/CoachView.swift index 26f5fb78bf..7872f13716 100644 --- a/Strand/Screens/CoachView.swift +++ b/Strand/Screens/CoachView.swift @@ -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) @@ -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() { diff --git a/Strand/System/DailyCoachNotifier.swift b/Strand/System/DailyCoachNotifier.swift new file mode 100644 index 0000000000..7f4c10dd3b --- /dev/null +++ b/Strand/System/DailyCoachNotifier.swift @@ -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) + } + } +} diff --git a/StrandTests/DailyCoachNotifierTests.swift b/StrandTests/DailyCoachNotifierTests.swift new file mode 100644 index 0000000000..900ce35266 --- /dev/null +++ b/StrandTests/DailyCoachNotifierTests.swift @@ -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 + )) + } +} diff --git a/android/app/src/main/java/com/noop/notif/ScheduledReportNotifier.kt b/android/app/src/main/java/com/noop/notif/ScheduledReportNotifier.kt index 0e4e22ac6a..a53daa3e4b 100644 --- a/android/app/src/main/java/com/noop/notif/ScheduledReportNotifier.kt +++ b/android/app/src/main/java/com/noop/notif/ScheduledReportNotifier.kt @@ -16,7 +16,8 @@ import kotlin.math.roundToInt // MARK: - Scheduled report notifications (#517) // // Two opt-in, default-OFF system notifications, no AI involved: -// 1. A MORNING RECAP (Charge + Rest) once a fresh night has been processed. +// 1. A MORNING RECAP (available Charge / Rest / HRV / resting HR / sleep) once a fresh night has +// been processed, plus training guidance only when Charge exists. // 2. A POST-WORKOUT SUMMARY (Effort + duration + avg HR) when a newly synced workout is first seen. // // Neither is alarm-precise: NOOP reads the strap over BLE and scores on a ~15-minute analytics pass, so a @@ -33,17 +34,17 @@ import kotlin.math.roundToInt * by ScheduledReportPolicyTest independently of the notification plumbing. */ object ScheduledReportPolicy { - /** Fire the morning recap at most once per REPORTED NIGHT: only when enabled, a recap value exists, and + /** Fire the morning recap at most once per REPORTED NIGHT: only when enabled, a metric exists, and * we haven't already posted for [reportDay]. [reportDay] is the day of the banked night the recap is * FOR (the resolved today-row's `day`), NOT the phone's calendar day — keying on the calendar day made * it re-fire at midnight for anyone up late, since the row still resolves to last night's until a new * night is banked (#567). */ fun shouldNotifyMorning( enabled: Boolean, - chargeOrRestPresent: Boolean, + metricsPresent: Boolean, lastNotifiedDay: String?, reportDay: String, - ): Boolean = enabled && chargeOrRestPresent && lastNotifiedDay != reportDay + ): Boolean = enabled && metricsPresent && lastNotifiedDay != reportDay /** Fire the post-workout summary only for a workout STRICTLY newer than the last one summarised, so a * re-sync of the same backlog never re-notifies. [lastWorkoutTs] is 0 before the first ever. */ @@ -53,18 +54,42 @@ object ScheduledReportPolicy { lastWorkoutTs: Long, ): Boolean = enabled && newestWorkoutTs != null && newestWorkoutTs > lastWorkoutTs - /** Title + body for the morning recap. Charge and Rest are each optional (a night can produce one - * without the other); absent ones are simply omitted — never shown as 0 or a guess. Returns null when - * neither is present (the caller shouldn't have been asked to build copy, but stay honest). */ - fun morningCopy(chargePct: Int?, restPct: Int?): Pair? { - val parts = ArrayList(2) - chargePct?.let { parts.add("Charge $it") } - restPct?.let { parts.add("Rest $it") } - if (parts.isEmpty()) return null - val title = "Good morning: last night's recap" - val body = parts.joinToString(" · ") + - ". Recovery from your strap, scored after it synced this morning." - return title to body + enum class MorningTrainingBand { RECOVERY, CONTROLLED, HARDER } + + data class MorningBrief( + val charge: Int?, + val rest: Int?, + val hrvMs: Int?, + val restingHr: Int?, + val sleepHours: Int?, + val trainingBand: MorningTrainingBand?, + ) + + /** Pure data selection for the morning recap. Every displayed number is rounded here, and that same + * rounded Charge drives the recommendation band so the text can never disagree with the number. A + * missing Charge omits the recommendation instead of fabricating a mid-band value. */ + fun morningBrief( + charge: Double?, + rest: Double?, + hrvMs: Double? = null, + restingHr: Int? = null, + sleepMinutes: Double? = null, + ): MorningBrief? { + val roundedCharge = charge?.roundToInt() + val roundedRest = rest?.roundToInt() + val roundedHrv = hrvMs?.roundToInt() + val roundedSleepHours = sleepMinutes?.div(60.0)?.roundToInt() + if (roundedCharge == null && roundedRest == null && roundedHrv == null && + restingHr == null && roundedSleepHours == null + ) return null + val trainingBand = roundedCharge?.let { + when { + it >= 67 -> MorningTrainingBand.HARDER + it >= 34 -> MorningTrainingBand.CONTROLLED + else -> MorningTrainingBand.RECOVERY + } + } + return MorningBrief(roundedCharge, roundedRest, roundedHrv, restingHr, roundedSleepHours, trainingBand) } /** Title + body for the post-workout summary. [effortDisplay] is already formatted on the user's @@ -104,23 +129,32 @@ object ScheduledReportNotifier { private const val WORKOUT_NOTIF_ID = 4209 /** - * Post the morning recap if enabled and not already posted today. [chargePct]/[restPct] are the - * just-computed Charge/Rest for the night (either may be null). No-op on every path that fails the - * policy, so the caller can fire it freely each time the days collector republishes. + * Post the morning recap if enabled and not already posted today. Every metric is optional and the + * recommendation is omitted when Charge is absent. No-op on every path that fails the policy, so the + * caller can fire it freely each time the days collector republishes. */ @SuppressLint("MissingPermission") // guarded by areNotificationsEnabled() + runCatching - fun onMorning(context: Context, reportDay: String, chargePct: Int?, restPct: Int?) { + fun onMorning( + context: Context, + reportDay: String, + charge: Double?, + rest: Double?, + hrvMs: Double?, + restingHr: Int?, + sleepMinutes: Double?, + ) { // reportDay is the banked night's day (the resolved today-row's `day`), NOT LocalDate.now() — the // calendar day rolls at midnight while the row still resolves to last night's until a new night is // banked, which re-fired the recap at the start of a new day for late-nighters (#567). + val brief = ScheduledReportPolicy.morningBrief(charge, rest, hrvMs, restingHr, sleepMinutes) ?: return if (!ScheduledReportPolicy.shouldNotifyMorning( enabled = NoopPrefs.morningReportEnabled(context), - chargeOrRestPresent = chargePct != null || restPct != null, + metricsPresent = true, lastNotifiedDay = NoopPrefs.reportMorningDay(context), reportDay = reportDay, ) ) return - val copy = ScheduledReportPolicy.morningCopy(chargePct, restPct) ?: return + val copy = morningCopy(context, brief) runCatching { if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return ensureChannel(context) @@ -131,6 +165,23 @@ object ScheduledReportNotifier { } } + private fun morningCopy(context: Context, brief: ScheduledReportPolicy.MorningBrief): Pair { + val parts = ArrayList(5) + brief.charge?.let { parts.add("${context.getString(R.string.today_metric_charge)} $it") } + brief.rest?.let { parts.add("${context.getString(R.string.today_metric_rest)} $it") } + brief.hrvMs?.let { parts.add("${context.getString(R.string.today_metric_hrv)} $it ms") } + brief.restingHr?.let { parts.add("${context.getString(R.string.l10n_insights_screen_rhr_04edf9b3)} $it bpm") } + brief.sleepHours?.let { parts.add("${context.getString(R.string.today_card_sleep)} $it h") } + val training = when (brief.trainingBand) { + ScheduledReportPolicy.MorningTrainingBand.HARDER -> context.getString(R.string.today_readiness_primed_summary) + ScheduledReportPolicy.MorningTrainingBand.CONTROLLED -> context.getString(R.string.today_readiness_strained_summary) + ScheduledReportPolicy.MorningTrainingBand.RECOVERY -> context.getString(R.string.today_readiness_run_down_summary) + null -> null + } + return context.getString(R.string.coach_morning_brief) to + (parts.joinToString(" · ") + (training?.let { ". $it" } ?: "")) + } + /** * Post the post-workout summary for [newestWorkoutTs] if it's strictly newer than the last summarised. * The copy fields are pre-resolved by the caller (it owns the profile + Effort-scale + repo), so this @@ -204,6 +255,3 @@ object ScheduledReportNotifier { } } } - -/** Round a 0–100 score to a whole number for display, or null if absent (never fabricate a 0). */ -internal fun Double?.scorePctOrNull(): Int? = this?.roundToInt() 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 99d26eaf9f..a699adbde3 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -48,7 +48,6 @@ import com.noop.notif.IllnessAlertNotifier import com.noop.notif.ScheduledReportNotifier import com.noop.notif.StrainTargetNotifier import com.noop.notif.ScheduledReportPolicy -import com.noop.notif.scorePctOrNull import com.noop.protocol.CommandNumber import com.noop.widget.WidgetSnapshot import com.noop.widget.WidgetSnapshotStore @@ -990,11 +989,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { if (previousAlert == null) { _healthAlert.value?.let { IllnessAlertNotifier.onEvaluated(appContext, it) } } - // Morning recap (#517) — opt-in, default OFF. Once today's row carries a banked night - // (totalSleepMin != null), post a one-per-day Charge + Rest recap. recovery == Charge; + // Morning recap (#517/#1736) — opt-in, default OFF. Once today's row carries a banked + // night, post a one-per-night summary of the available metrics. recovery == Charge; // Rest is recomputed from the night's totals via RestScorer (the same single source of - // truth Trends/Insights use). The notifier's persisted day gate makes this safe to call - // on every republish. Honest: a night with only one of the two scores omits the other. + // truth Trends/Insights use). The notifier's persisted gate makes this safe on every + // republish; missing values are omitted and missing Charge never fabricates advice. _today.value?.let { todayRow -> if (todayRow.totalSleepMin != null) { ScheduledReportNotifier.onMorning( @@ -1002,8 +1001,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // Key the once-per-recap gate on the banked NIGHT's day, not the calendar day — // otherwise the midnight rollover re-fires last night's recap for late-nighters (#567). reportDay = todayRow.day, - chargePct = todayRow.recovery.scorePctOrNull(), - restPct = RestScorer.restFromDaily(todayRow).scorePctOrNull(), + charge = todayRow.recovery, + rest = RestScorer.restFromDaily(todayRow), + hrvMs = todayRow.avgHrv, + restingHr = todayRow.restingHr, + sleepMinutes = todayRow.totalSleepMin, ) } // #593: once-a-day optimal-strain-reached nudge. Convert the stored 0-100 Effort to the diff --git a/android/app/src/main/java/com/noop/ui/CoachPrompts.kt b/android/app/src/main/java/com/noop/ui/CoachPrompts.kt index c7eddf12c0..6e650deac3 100644 --- a/android/app/src/main/java/com/noop/ui/CoachPrompts.kt +++ b/android/app/src/main/java/com/noop/ui/CoachPrompts.kt @@ -20,7 +20,8 @@ object CoachPrompts { } /** - * A question handed over by the Today Coach launcher sheet, for [CoachScreen] to send once (#1862). + * A question handed over by the Today Coach launcher sheet for [CoachScreen] to place in its composer + * (#1862/#1736). The user reviews and explicitly sends it there. * * Swift passes this on the shared `AICoachEngine`, which is an app-wide `EnvironmentObject`. Android has * no equivalent shared instance here: `CoachScreen` takes `viewModel()`, which is scoped to the nav @@ -28,14 +29,14 @@ object CoachPrompts { * the smallest thing that actually crosses that boundary. * * `@Volatile` because it is written on the main thread and read by the screen's first composition. - * Setting it performs NO network work by itself; the send still happens in the Coach screen, which owns - * the consent and error surface. + * Setting it performs NO network work by itself; the Coach screen only prepares a draft and keeps the + * consent, review, send, and error surfaces in one place. */ object CoachHandoff { @Volatile var pendingPrompt: String? = null - /** Take the pending question and clear it, so a recomposition cannot send it twice. */ + /** Take the pending question and clear it, so a recomposition cannot replace the draft twice. */ fun consume(): String? { val p = pendingPrompt pendingPrompt = null diff --git a/android/app/src/main/java/com/noop/ui/CoachScreen.kt b/android/app/src/main/java/com/noop/ui/CoachScreen.kt index d75037dddf..4af1017edf 100644 --- a/android/app/src/main/java/com/noop/ui/CoachScreen.kt +++ b/android/app/src/main/java/com/noop/ui/CoachScreen.kt @@ -76,18 +76,13 @@ import com.noop.ai.CustomAiAuthHeader @Composable fun CoachScreen(vm: CoachViewModel = viewModel()) { val context = LocalContext.current + // #1736: keep the Today handoff local until the user reviews and explicitly sends it. + val handedPrompt = remember { CoachHandoff.consume() } val keyVersion by vm.keyVersion.collectAsStateWithLifecycle() val provider by vm.provider.collectAsStateWithLifecycle() val customConnected by vm.customConnected.collectAsStateWithLifecycle() // Re-evaluate the gate whenever the stored key, provider, or custom-connect state changes. val configured = remember(keyVersion, provider, customConnected) { vm.isConfigured(context) } - // #1862: a question handed over by the Today launcher sheet. Consumed once — `consume()` clears it — - // so a recomposition cannot resend it, and only when the coach can actually send, so an unconfigured - // handoff degrades to showing setup rather than a failed request. Swift twin: CoachView's task. - LaunchedEffect(configured) { - val handed = CoachHandoff.consume() - if (handed != null && configured) vm.send(context, handed) - } // Same day-cycle gate as the liquid Today: the time-of-day sky settles behind the top content when the // user hasn't opted out; otherwise the scaffold paints the plain dark canvas. val showDayCycleBackground = remember { NoopPrefs.showDayCycleBackground(context) } @@ -107,7 +102,7 @@ fun CoachScreen(vm: CoachViewModel = viewModel()) { if (!configured) { CoachSetup(vm = vm) } else { - CoachChat(vm = vm) + CoachChat(vm = vm, initialPrompt = handedPrompt) } } } @@ -264,7 +259,7 @@ private fun CoachSetup(vm: CoachViewModel) { // MARK: - Chat (key saved) @Composable -private fun CoachChat(vm: CoachViewModel) { +private fun CoachChat(vm: CoachViewModel, initialPrompt: String?) { val context = LocalContext.current val messages by vm.messages.collectAsStateWithLifecycle() val sending by vm.sending.collectAsStateWithLifecycle() @@ -285,6 +280,13 @@ private fun CoachChat(vm: CoachViewModel) { // sit in composition state after it has been stored. var keyFix by remember { mutableStateOf("") } + LaunchedEffect(initialPrompt) { + if (!initialPrompt.isNullOrBlank()) { + input = initialPrompt + draftPrefs.edit().putString("draft", initialPrompt).apply() + } + } + // Refresh the contextual chips whenever the chat empties (so a fresh sync updates them) and // once on first show. Best-effort; the VM falls back to the generic set on any failure. LaunchedEffect(messages.isEmpty()) { diff --git a/android/app/src/test/java/com/noop/notif/ScheduledReportPolicyTest.kt b/android/app/src/test/java/com/noop/notif/ScheduledReportPolicyTest.kt index b241311126..3496466601 100644 --- a/android/app/src/test/java/com/noop/notif/ScheduledReportPolicyTest.kt +++ b/android/app/src/test/java/com/noop/notif/ScheduledReportPolicyTest.kt @@ -19,7 +19,7 @@ class ScheduledReportPolicyTest { @Test fun morningFiresWhenEnabledScorePresentAndNotYetToday() { assertTrue( ScheduledReportPolicy.shouldNotifyMorning( - enabled = true, chargeOrRestPresent = true, lastNotifiedDay = "2026-06-20", reportDay = "2026-06-21", + enabled = true, metricsPresent = true, lastNotifiedDay = "2026-06-20", reportDay = "2026-06-21", ), ) } @@ -27,7 +27,7 @@ class ScheduledReportPolicyTest { @Test fun morningSuppressedWhenDisabled() { assertFalse( ScheduledReportPolicy.shouldNotifyMorning( - enabled = false, chargeOrRestPresent = true, lastNotifiedDay = null, reportDay = "2026-06-21", + enabled = false, metricsPresent = true, lastNotifiedDay = null, reportDay = "2026-06-21", ), ) } @@ -35,7 +35,7 @@ class ScheduledReportPolicyTest { @Test fun morningSuppressedWhenAlreadyFiredToday() { assertFalse( ScheduledReportPolicy.shouldNotifyMorning( - enabled = true, chargeOrRestPresent = true, lastNotifiedDay = "2026-06-21", reportDay = "2026-06-21", + enabled = true, metricsPresent = true, lastNotifiedDay = "2026-06-21", reportDay = "2026-06-21", ), ) } @@ -43,7 +43,7 @@ class ScheduledReportPolicyTest { @Test fun morningSuppressedWhenNoScore() { assertFalse( ScheduledReportPolicy.shouldNotifyMorning( - enabled = true, chargeOrRestPresent = false, lastNotifiedDay = null, reportDay = "2026-06-21", + enabled = true, metricsPresent = false, lastNotifiedDay = null, reportDay = "2026-06-21", ), ) } @@ -57,7 +57,7 @@ class ScheduledReportPolicyTest { // resolved row is still the 06-20 night → reportDay = "2026-06-20", already notified → suppressed. assertFalse( ScheduledReportPolicy.shouldNotifyMorning( - enabled = true, chargeOrRestPresent = true, lastNotifiedDay = "2026-06-20", reportDay = "2026-06-20", + enabled = true, metricsPresent = true, lastNotifiedDay = "2026-06-20", reportDay = "2026-06-20", ), ) } @@ -86,23 +86,53 @@ class ScheduledReportPolicyTest { assertTrue(ScheduledReportPolicy.shouldNotifyWorkout(enabled = true, newestWorkoutTs = 1L, lastWorkoutTs = 0L)) } - // MARK: - morningCopy (honest omission) + // MARK: - morningBrief (round once, honest omission) - @Test fun morningCopyShowsBothScores() { - val (title, body) = ScheduledReportPolicy.morningCopy(chargePct = 72, restPct = 88)!! - assertTrue(title.contains("recap")) - assertTrue(body.contains("Charge 72")) - assertTrue(body.contains("Rest 88")) + @Test fun morningBriefIncludesEveryAvailableMetric() { + val brief = ScheduledReportPolicy.morningBrief( + charge = 72.2, rest = 88.4, hrvMs = 61.6, restingHr = 52, sleepMinutes = 479.0, + )!! + assertEquals(72, brief.charge) + assertEquals(88, brief.rest) + assertEquals(62, brief.hrvMs) + assertEquals(52, brief.restingHr) + assertEquals(8, brief.sleepHours) } - @Test fun morningCopyOmitsAbsentRestNeverShowsZero() { - val (_, body) = ScheduledReportPolicy.morningCopy(chargePct = 60, restPct = null)!! - assertTrue(body.contains("Charge 60")) - assertFalse(body.contains("Rest")) + @Test fun morningBriefUsesTheDisplayedRoundedChargeForBanding() { + val high = ScheduledReportPolicy.morningBrief(charge = 66.6, rest = null)!! + val controlled = ScheduledReportPolicy.morningBrief(charge = 66.4, rest = null)!! + assertEquals(67, high.charge) + assertEquals(ScheduledReportPolicy.MorningTrainingBand.HARDER, high.trainingBand) + assertEquals(66, controlled.charge) + assertEquals(ScheduledReportPolicy.MorningTrainingBand.CONTROLLED, controlled.trainingBand) } - @Test fun morningCopyNullWhenNeitherPresent() { - assertNull(ScheduledReportPolicy.morningCopy(chargePct = null, restPct = null)) + @Test fun morningBriefPinsBothBandBoundaries() { + assertEquals( + ScheduledReportPolicy.MorningTrainingBand.RECOVERY, + ScheduledReportPolicy.morningBrief(charge = 33.4, rest = null)!!.trainingBand, + ) + assertEquals( + ScheduledReportPolicy.MorningTrainingBand.CONTROLLED, + ScheduledReportPolicy.morningBrief(charge = 33.5, rest = null)!!.trainingBand, + ) + assertEquals( + ScheduledReportPolicy.MorningTrainingBand.HARDER, + ScheduledReportPolicy.morningBrief(charge = 66.5, rest = null)!!.trainingBand, + ) + } + + @Test fun morningBriefWithNoChargeNeverInventsTrainingAdvice() { + val brief = ScheduledReportPolicy.morningBrief( + charge = null, rest = null, hrvMs = 58.0, restingHr = null, sleepMinutes = null, + )!! + assertNull(brief.charge) + assertNull(brief.trainingBand) + } + + @Test fun morningBriefNullWhenEveryMetricIsAbsent() { + assertNull(ScheduledReportPolicy.morningBrief(charge = null, rest = null)) } // MARK: - workoutCopy