From 8a869b39a6ff29979310fd9236bb43ebc25c1973 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Jun 2026 20:51:47 +0000 Subject: [PATCH 1/9] Add sleep bank insights with auto-selected flattering windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces SleepInsightsEngine that scans 3–14 day lookbacks, picks congratulation and motivator windows, and builds narrative copy with human-readable catch-up fractions. Shows a Sleep Insight card below the sleep balance card and omits duplicate recommendation footer text when the insight covers uncatchable debt. Co-authored-by: Greg --- Bedtime/Bedtime/ContentView.swift | 12 + Bedtime/Bedtime/Models/SleepData.swift | 4 +- Bedtime/Bedtime/Models/SleepInsights.swift | 294 ++++++++++++++++++ Bedtime/Bedtime/Models/ViewModel.swift | 4 +- .../Utils/ProgressFractionFormatter.swift | 61 ++++ .../Views/BedtimeRecommendationCard.swift | 14 +- Bedtime/Bedtime/Views/SleepInsightsCard.swift | 103 ++++++ 7 files changed, 482 insertions(+), 10 deletions(-) create mode 100644 Bedtime/Bedtime/Models/SleepInsights.swift create mode 100644 Bedtime/Bedtime/Utils/ProgressFractionFormatter.swift create mode 100644 Bedtime/Bedtime/Views/SleepInsightsCard.swift diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 24fba95..62e0fc3 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -57,6 +57,14 @@ struct ContentView: View { ) } + private var sleepBankInsight: SleepBankInsight? { + SleepInsightsEngine.generateInsight( + sleepSessions: healthKitManager.sleepSessions, + goalHours: userPreferences.sleepGoalHours, + maxSleepHours: userPreferences.maxSleepHoursPerNight + ) + } + var body: some View { let isBeforeEvening = Calendar.current.component(.hour, from: Date()) < 18 NavigationStack { @@ -80,6 +88,10 @@ struct ContentView: View { SleepBankCard(sleepBank: sleepBank) + if let sleepBankInsight { + SleepInsightsCard(insight: sleepBankInsight) + } + if isBeforeEvening { BedtimeRecommendationCard(recommendation: bedtimeRecommendation) } else { diff --git a/Bedtime/Bedtime/Models/SleepData.swift b/Bedtime/Bedtime/Models/SleepData.swift index 0020e59..79ffa87 100644 --- a/Bedtime/Bedtime/Models/SleepData.swift +++ b/Bedtime/Bedtime/Models/SleepData.swift @@ -85,7 +85,7 @@ extension HKCategoryValueSleepAnalysis { } } -struct SleepBank { +struct SleepBank: Equatable { let currentBalance: Double // in hours let goalHours: Double let averageHours: Double? @@ -118,5 +118,5 @@ struct BedtimeRecommendation { let recommendedBedtime: Date let wakeTime: Date let targetSleepDuration: Double // in hours - let reason: String + let reason: String? } diff --git a/Bedtime/Bedtime/Models/SleepInsights.swift b/Bedtime/Bedtime/Models/SleepInsights.swift new file mode 100644 index 0000000..5288efd --- /dev/null +++ b/Bedtime/Bedtime/Models/SleepInsights.swift @@ -0,0 +1,294 @@ +// +// SleepInsights.swift +// Bedtime +// +// Heuristics that pick flattering congrats windows and motivating debt +// windows across lookback durations, then weave them into a narrative. +// + +import Foundation + +struct SleepWindowBalance: Equatable { + let days: Int + let balance: Double + let sleepBank: SleepBank + + var isAhead: Bool { balance >= 0 } + var aheadHours: Double { max(0, balance) } + var behindHours: Double { max(0, -balance) } +} + +struct SleepBankInsight: Equatable { + let message: String + let congratulationWindow: SleepWindowBalance? + let motivatorWindow: SleepWindowBalance? + let motivatorIsCatchable: Bool +} + +enum SleepInsightsEngine { + /// Lookback durations scanned for local minima / motivators (matches Settings range). + static let windowRange = 3...14 + + static func generateInsight( + sleepSessions: [Date: [SleepSession]], + goalHours: Double, + maxSleepHours: Double + ) -> SleepBankInsight? { + let snapshots = windowBalances(sleepSessions: sleepSessions, goalHours: goalHours) + guard snapshots.contains(where: { $0.sleepBank.averageHours != nil }) else { + return nil + } + + let congratulation = selectCongratulation(from: snapshots) + let motivator = selectMotivator( + from: snapshots, + goalHours: goalHours, + maxSleepHours: maxSleepHours + ) + + guard let message = buildNarrative( + congratulation: congratulation, + motivator: motivator, + goalHours: goalHours, + maxSleepHours: maxSleepHours + ) else { + return nil + } + + let motivatorIsCatchable = motivator.map { + isCatchableInOneNight(balance: $0.balance, goalHours: goalHours, maxSleepHours: maxSleepHours) + } ?? false + + return SleepBankInsight( + message: message, + congratulationWindow: congratulation, + motivatorWindow: motivator, + motivatorIsCatchable: motivatorIsCatchable + ) + } + + // MARK: - Window series + + static func windowBalances( + sleepSessions: [Date: [SleepSession]], + goalHours: Double + ) -> [SleepWindowBalance] { + windowRange.map { days in + let bank = ViewModel.calculateSleepBank( + sleepSessions: sleepSessions, + goalHours: goalHours, + recentDays: days + ) + return SleepWindowBalance(days: days, balance: bank.currentBalance, sleepBank: bank) + } + } + + // MARK: - Selection heuristics + + /// Among local minima with a non-negative balance, pick the longest lookback. + /// Falls back to the longest non-negative window when no local minimum qualifies. + static func selectCongratulation(from snapshots: [SleepWindowBalance]) -> SleepWindowBalance? { + let nonNegativeMinima = localMinima(in: snapshots).filter(\.isAhead) + if let longestMinimum = nonNegativeMinima.max(by: { $0.days < $1.days }) { + return longestMinimum + } + return snapshots.filter(\.isAhead).max(by: { $0.days < $1.days }) + } + + /// Prefers the most behind lookback that can still be caught up in one night. + /// Falls back to the shortest behind window when debt is too large to clear tonight. + static func selectMotivator( + from snapshots: [SleepWindowBalance], + goalHours: Double, + maxSleepHours: Double + ) -> SleepWindowBalance? { + let behind = snapshots.filter { !$0.isAhead } + guard !behind.isEmpty else { return nil } + + if let catchable = behind + .filter({ isCatchableInOneNight(balance: $0.balance, goalHours: goalHours, maxSleepHours: maxSleepHours) }) + .min(by: { $0.balance < $1.balance }) { + return catchable + } + + return behind.min(by: { $0.days < $1.days }) + } + + /// Whether tonight's recommended sleep duration can fully cover this window's debt. + static func isCatchableInOneNight( + balance: Double, + goalHours: Double, + maxSleepHours: Double + ) -> Bool { + let sleepNeeded = goalHours - balance + return sleepNeeded <= maxSleepHours + } + + /// A point lower than both neighbors (plateaus count as minima). + static func localMinima(in snapshots: [SleepWindowBalance]) -> [SleepWindowBalance] { + guard !snapshots.isEmpty else { return [] } + guard snapshots.count > 1 else { return snapshots } + + return snapshots.enumerated().compactMap { index, snapshot in + let previous = index > 0 ? snapshots[index - 1].balance : .infinity + let next = index < snapshots.count - 1 ? snapshots[index + 1].balance : .infinity + return snapshot.balance <= previous && snapshot.balance <= next ? snapshot : nil + } + } + + // MARK: - Narrative + + static func buildNarrative( + congratulation: SleepWindowBalance?, + motivator: SleepWindowBalance?, + goalHours: Double, + maxSleepHours: Double + ) -> String? { + switch (congratulation, motivator) { + case let (congrats?, motivator?) where congrats.isAhead && !motivator.isAhead: + let catchable = isCatchableInOneNight( + balance: motivator.balance, + goalHours: goalHours, + maxSleepHours: maxSleepHours + ) + return combinedNarrative( + congratulation: congrats, + motivator: motivator, + motivatorIsCatchable: catchable, + goalHours: goalHours, + maxSleepHours: maxSleepHours + ) + + case let (congrats?, _) where congrats.isAhead: + return aheadNarrative(window: congrats) + + case let (_, motivator?) where !motivator.isAhead: + let catchable = isCatchableInOneNight( + balance: motivator.balance, + goalHours: goalHours, + maxSleepHours: maxSleepHours + ) + return behindNarrative( + window: motivator, + isCatchable: catchable, + goalHours: goalHours, + maxSleepHours: maxSleepHours + ) + + default: + return nil + } + } + + private static func combinedNarrative( + congratulation: SleepWindowBalance, + motivator: SleepWindowBalance, + motivatorIsCatchable: Bool, + goalHours: Double, + maxSleepHours: Double + ) -> String { + let aheadPhrase = formatHoursNaturally(congratulation.aheadHours) + let behindPhrase = formatHoursNaturally(motivator.behindHours) + let motivatorDays = dayCountPhrase(motivator.days) + + if congratulation.days == motivator.days { + return """ + You're \(aheadPhrase) ahead over the last \(dayCountPhrase(congratulation.days)). \ + Still feeling tired? A solid night tonight could help you build on that. + """ + } + + if motivatorIsCatchable { + return """ + You're \(aheadPhrase) ahead over the last \(dayCountPhrase(congratulation.days))! \ + Still feeling tired? You're a bit behind over the last \(motivatorDays) — about \(behindPhrase). \ + Want to try make up for it tonight? + """ + } + + let partialCatchUp = partialCatchUpClause( + debtHours: motivator.behindHours, + goalHours: goalHours, + maxSleepHours: maxSleepHours, + capitalizeBut: false + ) + + return """ + You're \(aheadPhrase) ahead over the last \(dayCountPhrase(congratulation.days))! \ + Still feeling tired? You're \(behindPhrase) behind over the last \(motivatorDays) — \ + \(partialCatchUp) + """ + } + + private static func aheadNarrative(window: SleepWindowBalance) -> String { + let aheadPhrase = formatHoursNaturally(window.aheadHours) + return "You're \(aheadPhrase) ahead over the last \(dayCountPhrase(window.days)). Nice work — keep it up!" + } + + private static func behindNarrative( + window: SleepWindowBalance, + isCatchable: Bool, + goalHours: Double, + maxSleepHours: Double + ) -> String { + let behindPhrase = formatHoursNaturally(window.behindHours) + if isCatchable { + return "You're \(behindPhrase) behind over the last \(dayCountPhrase(window.days)). Want to try make up for it tonight?" + } + + let partialCatchUp = partialCatchUpClause( + debtHours: window.behindHours, + goalHours: goalHours, + maxSleepHours: maxSleepHours + ) + + return """ + You're \(behindPhrase) behind over the last \(dayCountPhrase(window.days)). \ + \(partialCatchUp) + """ + } + + private static func partialCatchUpClause( + debtHours: Double, + goalHours: Double, + maxSleepHours: Double, + capitalizeBut: Bool = true + ) -> String { + let extraTonight = max(0, maxSleepHours - goalHours) + let but = capitalizeBut ? "But" : "but" + guard extraTonight > 0.01, debtHours > 0 else { + return "\(but) aim for as much sleep as you can tonight." + } + + let extraPhrase = formatHoursNaturally(extraTonight) + let fraction = ProgressFractionFormatter.progressPhrase( + debtHours: debtHours, + extraSleepHours: extraTonight + ) + + if fraction == "really close" { + return "\(but) you can reasonably add \(extraPhrase), getting you really close." + } + + return "\(but) you can reasonably add \(extraPhrase), getting you \(fraction) there." + } + + static func dayCountPhrase(_ days: Int) -> String { + days == 1 ? "day" : "\(days) days" + } + + static func formatHoursNaturally(_ hours: Double) -> String { + let totalMinutes = max(1, Int((abs(hours) * 60).rounded())) + let wholeHours = totalMinutes / 60 + let minutes = totalMinutes % 60 + + switch (wholeHours, minutes) { + case (0, let m): + return "\(m) minute\(m == 1 ? "" : "s")" + case (let h, 0): + return "\(h) hour\(h == 1 ? "" : "s")" + case (let h, let m): + return "\(h) hour\(h == 1 ? "" : "s") \(m) minute\(m == 1 ? "" : "s")" + } + } +} diff --git a/Bedtime/Bedtime/Models/ViewModel.swift b/Bedtime/Bedtime/Models/ViewModel.swift index a222f1b..381d9e4 100644 --- a/Bedtime/Bedtime/Models/ViewModel.swift +++ b/Bedtime/Bedtime/Models/ViewModel.swift @@ -62,12 +62,12 @@ class ViewModel { var totalSleepNeeded = sleepGoal - sleepBank.currentBalance // Generate reason - let reason: String + let reason: String? if sleepBank.averageHours == nil { reason = "No data so far, just aim for your goal" } else if totalSleepNeeded > maxSleepHours { totalSleepNeeded = maxSleepHours - reason = "You can't catch up in one night, so just get as much as possible." + reason = nil } else if sleepBank.isInDebt { let debtHours = sleepBank.debtHours reason = "You need \(String(format: "%.1f", totalSleepNeeded)) hours tonight to catch up on your \(String(format: "%.1f", debtHours))-hour sleep debt." diff --git a/Bedtime/Bedtime/Utils/ProgressFractionFormatter.swift b/Bedtime/Bedtime/Utils/ProgressFractionFormatter.swift new file mode 100644 index 0000000..695e5b0 --- /dev/null +++ b/Bedtime/Bedtime/Utils/ProgressFractionFormatter.swift @@ -0,0 +1,61 @@ +// +// ProgressFractionFormatter.swift +// Bedtime +// +// Human-readable progress phrases for partial catch-up messaging. +// + +import Foundation + +enum ProgressFractionFormatter { + /// Describes how far extra sleep tonight gets you toward clearing `debtHours`. + static func progressPhrase(debtHours: Double, extraSleepHours: Double) -> String { + guard debtHours > 0, extraSleepHours > 0 else { + return "a little closer" + } + + let progress = min(extraSleepHours / debtHours, 1) + return progressPhrase(for: progress) + } + + static func progressPhrase(for progress: Double) -> String { + let p = min(max(progress, 0), 1) + + if p >= 0.9 { + return "really close" + } + + let (numerator, denominator, value) = bestSimpleFraction(for: p) + let error = abs(p - value) + let qualifier = error <= 0.07 ? "almost" : "about" + + if denominator == 2 { + if p > 0.5 { + return "over halfway" + } + return "\(qualifier) halfway" + } + + return "\(qualifier) \(numerator)/\(denominator) of the way" + } + + private static func bestSimpleFraction(for progress: Double) -> (numerator: Int, denominator: Int, value: Double) { + var best: (numerator: Int, denominator: Int, value: Double)? + var bestScore = Double.infinity + + for denominator in 2...5 { + for numerator in 1.. Date: Thu, 2 Jul 2026 18:53:42 +0000 Subject: [PATCH 2/9] Fix consistent alignment across card section headers Introduce a shared CardHeader component with a fixed-width, center-aligned icon column and uniform spacing so titles line up across Sleep Balance, Sleep Insight, Tonight's Recommendation, and all other dashboard cards. Co-authored-by: Greg --- Bedtime/Bedtime/Views/SleepInsightsCard.swift | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/Bedtime/Bedtime/Views/SleepInsightsCard.swift b/Bedtime/Bedtime/Views/SleepInsightsCard.swift index b43b12f..ff7f50b 100644 --- a/Bedtime/Bedtime/Views/SleepInsightsCard.swift +++ b/Bedtime/Bedtime/Views/SleepInsightsCard.swift @@ -29,23 +29,15 @@ struct SleepInsightsCard: View { var body: some View { CardComponent { - HStack(alignment: .top, spacing: 12) { - Image(systemName: iconName) - .font(.title2) - .foregroundColor(accentColor) - .frame(width: Constants.iconWidth) - - VStack(alignment: .leading, spacing: 6) { - Text("Sleep Insight") - .font(.headline) - - Text(insight.message) - .font(.subheadline) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer(minLength: 0) + CardHeader( + icon: iconName, + iconColor: accentColor, + title: "Sleep Insight" + ) { + Text(insight.message) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) } } } From d9dcb7edc9df7392c30160672706aedbc3169697 Mon Sep 17 00:00:00 2001 From: Greg Date: Fri, 3 Jul 2026 11:01:50 -0700 Subject: [PATCH 3/9] better --- Bedtime/Bedtime/Views/SleepInsightsCard.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Bedtime/Bedtime/Views/SleepInsightsCard.swift b/Bedtime/Bedtime/Views/SleepInsightsCard.swift index ff7f50b..3d7bb49 100644 --- a/Bedtime/Bedtime/Views/SleepInsightsCard.swift +++ b/Bedtime/Bedtime/Views/SleepInsightsCard.swift @@ -29,11 +29,13 @@ struct SleepInsightsCard: View { var body: some View { CardComponent { - CardHeader( - icon: iconName, - iconColor: accentColor, - title: "Sleep Insight" - ) { + VStack(alignment: .leading, spacing: 12) { + CardHeader( + icon: iconName, + iconColor: accentColor, + title: "Sleep Insight" + ) + Text(insight.message) .font(.subheadline) .foregroundColor(.secondary) From 26cd22d0bcdec895d01becc8b8075b56dcbb9b2f Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 5 Jul 2026 14:30:25 -0700 Subject: [PATCH 4/9] fix build --- Bedtime/Bedtime/ContentView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 62e0fc3..c8cbaca 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -61,7 +61,10 @@ struct ContentView: View { SleepInsightsEngine.generateInsight( sleepSessions: healthKitManager.sleepSessions, goalHours: userPreferences.sleepGoalHours, - maxSleepHours: userPreferences.maxSleepHoursPerNight + maxSleepHours: SleepWindow.maxSleepHours( + earliestBedtime: userPreferences.earliestReasonableBedtime, + wakeTime: userPreferences.wakeTime + ) ) } From 745ae22e06054404c10b9aecbe4fffdb3acb5442 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 5 Jul 2026 14:56:51 -0700 Subject: [PATCH 5/9] automatic refresh --- Bedtime/Bedtime/Models/HealthKitManager.swift | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index 489b5ca..5add6f4 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -31,6 +31,9 @@ class HealthKitManager: ObservableObject { private let sourcePreferences: SourcePreferences private var rawSleepSamples: [HKCategorySample] = [] private var cancellables = Set() + /// Long-lived query that re-loads data whenever HealthKit sleep samples change. + /// Registered once, after the first successful fetch; torn down in `deinit`. + private var observerQuery: HKObserverQuery? @Published private(set) var permissionsRequestState: PermissionsRequestState = .loading @Published var sleepSessions: [Date: [SleepSession]] = [:] @@ -56,6 +59,12 @@ class HealthKitManager: ObservableObject { .store(in: &cancellables) } + deinit { + if let observerQuery { + healthStore.stop(observerQuery) + } + } + private func checkHealthKitAvailability() throws { guard HKHealthStore.isHealthDataAvailable() else { throw NSError(domain: "HealthKitManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "HealthKit is not available on this device"]) @@ -90,6 +99,7 @@ class HealthKitManager: ObservableObject { do { try await requestAuthorization() try await loadSleepData() + startObservingSleepChanges() } catch { errorMessage = error.localizedDescription throw error @@ -100,6 +110,64 @@ class HealthKitManager: ObservableObject { _ = try await [fetchSleepDataForDisplay(), discoverAvailableSources()] } + /// Registers an `HKObserverQuery` so the app re-loads sleep data whenever + /// HealthKit gains new samples — no manual pull-to-refresh needed — and enables + /// background delivery so those updates arrive even while the app is suspended. + /// Idempotent: safe to call from every `fetchSleepData()`; only registers once. + private func startObservingSleepChanges() { + guard observerQuery == nil else { return } + + let query = HKObserverQuery( + sampleType: HKCategoryType.sleepAnalysis, + predicate: nil + ) { [weak self] _, completionHandler, error in + // HealthKit invokes this handler off the main thread. We must always call + // `completionHandler()` so HealthKit releases its background assertion and + // stops retrying the notification. + guard let self else { + completionHandler() + return + } + + if let error { + Task { @MainActor in + self.errorMessage = "Sleep data observer error: \(error.localizedDescription)" + } + completionHandler() + return + } + + Task { @MainActor in + do { + try await self.loadSleepData() + } catch { + self.errorMessage = "Failed to refresh sleep data: \(error.localizedDescription)" + } + completionHandler() + } + } + + observerQuery = query + healthStore.execute(query) + enableBackgroundDelivery() + } + + /// Asks HealthKit to wake the app (subject to system throttling) whenever new + /// sleep samples land, so the observer query fires while backgrounded. Requires + /// the `com.apple.developer.healthkit.background-delivery` entitlement. + private func enableBackgroundDelivery() { + healthStore.enableBackgroundDelivery( + for: HKCategoryType.sleepAnalysis, + frequency: .immediate + ) { [weak self] _, error in + guard let error else { return } + let message = "Failed to enable background updates: \(error.localizedDescription)" + Task { @MainActor [weak self] in + self?.errorMessage = message + } + } + } + private func fetchSleepDataForDisplay() async throws { let calendar = Calendar.current let endDate = Date() From 2ece5e0ef8bc42dccc8f7857474ac32e3cab64a0 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 5 Jul 2026 14:57:41 -0700 Subject: [PATCH 6/9] scenePhase --- Bedtime/Bedtime/ContentView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index c8cbaca..1819c41 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -11,6 +11,7 @@ import SwiftData struct ContentView: View { @Environment(\.modelContext) private var modelContext @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.scenePhase) private var scenePhase @Query private var preferences: [UserPreferences] @StateObject private var sourcePreferences: SourcePreferences @StateObject private var healthKitManager: HealthKitManager @@ -146,6 +147,13 @@ struct ContentView: View { .task { try? await healthKitManager.fetchSleepData() } + .onChange(of: scenePhase) { _, newPhase in + // Refresh on return from background: the observer query's background + // delivery is throttled, and `.task` doesn't re-run on resume, so this + // covers data added in the Health app while we were suspended. + guard newPhase == .active else { return } + Task { try? await healthKitManager.fetchSleepData() } + } } } From ae9bc6d573ffb2ea69fe7aa2ef1fd173d29d6496 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 5 Jul 2026 15:00:24 -0700 Subject: [PATCH 7/9] single task --- Bedtime/Bedtime/Models/HealthKitManager.swift | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index 5add6f4..8063ad4 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -121,29 +121,22 @@ class HealthKitManager: ObservableObject { sampleType: HKCategoryType.sleepAnalysis, predicate: nil ) { [weak self] _, completionHandler, error in - // HealthKit invokes this handler off the main thread. We must always call + // HealthKit invokes this handler off the main thread, so hop to the main + // actor to touch `errorMessage`/`loadSleepData`. Always call // `completionHandler()` so HealthKit releases its background assertion and // stops retrying the notification. - guard let self else { - completionHandler() - return - } - - if let error { - Task { @MainActor in + Task { @MainActor in + defer { completionHandler() } + guard let self else { return } + if let error { self.errorMessage = "Sleep data observer error: \(error.localizedDescription)" + return } - completionHandler() - return - } - - Task { @MainActor in do { try await self.loadSleepData() } catch { self.errorMessage = "Failed to refresh sleep data: \(error.localizedDescription)" } - completionHandler() } } From 70fb413ad7e315ab686d5b2e9728dd1e8765f068 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 5 Jul 2026 15:08:58 -0700 Subject: [PATCH 8/9] clear store on retrieval error --- Bedtime/Bedtime/BedtimeApp.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Bedtime/Bedtime/BedtimeApp.swift b/Bedtime/Bedtime/BedtimeApp.swift index 5288669..98271ac 100644 --- a/Bedtime/Bedtime/BedtimeApp.swift +++ b/Bedtime/Bedtime/BedtimeApp.swift @@ -20,7 +20,24 @@ struct BedtimeApp: App { do { return try ModelContainer(for: schema, configurations: [modelConfiguration]) } catch { - fatalError("Could not create ModelContainer: \(error)") + // The store on disk can be incompatible with the current schema after a + // model change that SwiftData can't lightweight-migrate (e.g. the max-hours + // → earliestReasonableBedtime refactor). Rather than crash on open for + // anyone upgrading, discard the stale store and rebuild it. UserPreferences + // only holds user settings, which fall back to sensible defaults. + if let storeURL = modelConfiguration.url as URL? { + let fileManager = FileManager.default + for suffix in ["", "-shm", "-wal"] { + let url = URL(fileURLWithPath: storeURL.path + suffix) + try? fileManager.removeItem(at: url) + } + } + + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer after resetting the store: \(error)") + } } }() From 9751ee005c665e7064470eef0c7a2d5f769dc89f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 06:57:01 +0000 Subject: [PATCH 9/9] Pick longest non-negative window for congrats, drop local minima Local minima excluded longer flattering spans when adding a day improved the balance. Congratulations now uses the longest lookback still ahead. Co-authored-by: Greg --- Bedtime/Bedtime/Models/SleepInsights.swift | 23 +++------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/Bedtime/Bedtime/Models/SleepInsights.swift b/Bedtime/Bedtime/Models/SleepInsights.swift index 5288efd..d721814 100644 --- a/Bedtime/Bedtime/Models/SleepInsights.swift +++ b/Bedtime/Bedtime/Models/SleepInsights.swift @@ -26,7 +26,7 @@ struct SleepBankInsight: Equatable { } enum SleepInsightsEngine { - /// Lookback durations scanned for local minima / motivators (matches Settings range). + /// Lookback durations scanned for motivator windows (matches Settings range). static let windowRange = 3...14 static func generateInsight( @@ -85,14 +85,9 @@ enum SleepInsightsEngine { // MARK: - Selection heuristics - /// Among local minima with a non-negative balance, pick the longest lookback. - /// Falls back to the longest non-negative window when no local minimum qualifies. + /// Longest lookback where cumulative balance is still non-negative. static func selectCongratulation(from snapshots: [SleepWindowBalance]) -> SleepWindowBalance? { - let nonNegativeMinima = localMinima(in: snapshots).filter(\.isAhead) - if let longestMinimum = nonNegativeMinima.max(by: { $0.days < $1.days }) { - return longestMinimum - } - return snapshots.filter(\.isAhead).max(by: { $0.days < $1.days }) + snapshots.filter(\.isAhead).max(by: { $0.days < $1.days }) } /// Prefers the most behind lookback that can still be caught up in one night. @@ -124,18 +119,6 @@ enum SleepInsightsEngine { return sleepNeeded <= maxSleepHours } - /// A point lower than both neighbors (plateaus count as minima). - static func localMinima(in snapshots: [SleepWindowBalance]) -> [SleepWindowBalance] { - guard !snapshots.isEmpty else { return [] } - guard snapshots.count > 1 else { return snapshots } - - return snapshots.enumerated().compactMap { index, snapshot in - let previous = index > 0 ? snapshots[index - 1].balance : .infinity - let next = index < snapshots.count - 1 ? snapshots[index + 1].balance : .infinity - return snapshot.balance <= previous && snapshot.balance <= next ? snapshot : nil - } - } - // MARK: - Narrative static func buildNarrative(