From 8a869b39a6ff29979310fd9236bb43ebc25c1973 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Jun 2026 20:51:47 +0000 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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( From 65915c978ac9014f1de4f074e8107fd9eb0897b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 07:12:46 +0000 Subject: [PATCH 10/10] Exclude incomplete current day from sleep bank before wake time When the calendar day has no sleep data yet and it is still before the user's wake time, anchor sleep bank and insight lookback windows to end of yesterday. This prevents the window from shifting at midnight and incorrectly extending the ahead/behind day count. Co-authored-by: Greg --- Bedtime/Bedtime/ContentView.swift | 4 ++- Bedtime/Bedtime/Models/SleepInsights.swift | 13 +++++++-- Bedtime/Bedtime/Models/ViewModel.swift | 11 ++++++-- Bedtime/Bedtime/Utils/SleepWindow.swift | 32 ++++++++++++++++++++++ 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 1819c41..6582649 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -45,7 +45,8 @@ struct ContentView: View { ViewModel.calculateSleepBank( sleepSessions: healthKitManager.sleepSessions, goalHours: userPreferences.sleepGoalHours, - recentDays: userPreferences.sleepBankDays + recentDays: userPreferences.sleepBankDays, + wakeTime: userPreferences.wakeTime ) } @@ -62,6 +63,7 @@ struct ContentView: View { SleepInsightsEngine.generateInsight( sleepSessions: healthKitManager.sleepSessions, goalHours: userPreferences.sleepGoalHours, + wakeTime: userPreferences.wakeTime, maxSleepHours: SleepWindow.maxSleepHours( earliestBedtime: userPreferences.earliestReasonableBedtime, wakeTime: userPreferences.wakeTime diff --git a/Bedtime/Bedtime/Models/SleepInsights.swift b/Bedtime/Bedtime/Models/SleepInsights.swift index d721814..a861564 100644 --- a/Bedtime/Bedtime/Models/SleepInsights.swift +++ b/Bedtime/Bedtime/Models/SleepInsights.swift @@ -32,9 +32,14 @@ enum SleepInsightsEngine { static func generateInsight( sleepSessions: [Date: [SleepSession]], goalHours: Double, + wakeTime: Date, maxSleepHours: Double ) -> SleepBankInsight? { - let snapshots = windowBalances(sleepSessions: sleepSessions, goalHours: goalHours) + let snapshots = windowBalances( + sleepSessions: sleepSessions, + goalHours: goalHours, + wakeTime: wakeTime + ) guard snapshots.contains(where: { $0.sleepBank.averageHours != nil }) else { return nil } @@ -71,13 +76,15 @@ enum SleepInsightsEngine { static func windowBalances( sleepSessions: [Date: [SleepSession]], - goalHours: Double + goalHours: Double, + wakeTime: Date ) -> [SleepWindowBalance] { windowRange.map { days in let bank = ViewModel.calculateSleepBank( sleepSessions: sleepSessions, goalHours: goalHours, - recentDays: days + recentDays: days, + wakeTime: wakeTime ) return SleepWindowBalance(days: days, balance: bank.currentBalance, sleepBank: bank) } diff --git a/Bedtime/Bedtime/Models/ViewModel.swift b/Bedtime/Bedtime/Models/ViewModel.swift index 381d9e4..9cae4b4 100644 --- a/Bedtime/Bedtime/Models/ViewModel.swift +++ b/Bedtime/Bedtime/Models/ViewModel.swift @@ -11,10 +11,17 @@ class ViewModel { static func calculateSleepBank( sleepSessions: [Date: [SleepSession]], goalHours: Double, - recentDays: Int + recentDays: Int, + wakeTime: Date, + referenceDate: Date = Date() ) -> SleepBank { let calendar = Calendar.current - let endDate = Date() + let endDate = SleepWindow.effectiveSleepBankEndDate( + now: referenceDate, + wakeTime: wakeTime, + sleepSessions: sleepSessions, + calendar: calendar + ) let startDate = calendar.date(byAdding: .day, value: -recentDays, to: endDate) ?? endDate // Filter sessions from the last N days diff --git a/Bedtime/Bedtime/Utils/SleepWindow.swift b/Bedtime/Bedtime/Utils/SleepWindow.swift index f9c6551..b3a9c89 100644 --- a/Bedtime/Bedtime/Utils/SleepWindow.swift +++ b/Bedtime/Bedtime/Utils/SleepWindow.swift @@ -70,4 +70,36 @@ struct SleepWindow { let components = calendar.dateComponents([.hour, .minute], from: date) return (components.hour ?? 0) * 60 + (components.minute ?? 0) } + + /// End of the sleep-bank lookback window. Before today's wake time, if today has no + /// recorded sleep yet, the current calendar day is still in progress — use end of + /// yesterday so midnight doesn't shift the lookback window. + static func effectiveSleepBankEndDate( + now: Date = Date(), + wakeTime: Date, + sleepSessions: [Date: [SleepSession]], + calendar: Calendar = .current + ) -> Date { + let today = calendar.startOfDay(for: now) + let todayHasData = !(sleepSessions[today]?.isEmpty ?? true) + if todayHasData { + return now + } + + let wakeComponents = calendar.dateComponents([.hour, .minute], from: wakeTime) + guard let todayWake = calendar.date( + bySettingHour: wakeComponents.hour ?? 0, + minute: wakeComponents.minute ?? 0, + second: 0, + of: today + ) else { + return now + } + + if now < todayWake { + return calendar.date(byAdding: .second, value: -1, to: today) ?? now + } + + return now + } }