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)") + } } }() diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 24fba95..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 @@ -57,6 +58,17 @@ struct ContentView: View { ) } + private var sleepBankInsight: SleepBankInsight? { + SleepInsightsEngine.generateInsight( + sleepSessions: healthKitManager.sleepSessions, + goalHours: userPreferences.sleepGoalHours, + maxSleepHours: SleepWindow.maxSleepHours( + earliestBedtime: userPreferences.earliestReasonableBedtime, + wakeTime: userPreferences.wakeTime + ) + ) + } + var body: some View { let isBeforeEvening = Calendar.current.component(.hour, from: Date()) < 18 NavigationStack { @@ -80,6 +92,10 @@ struct ContentView: View { SleepBankCard(sleepBank: sleepBank) + if let sleepBankInsight { + SleepInsightsCard(insight: sleepBankInsight) + } + if isBeforeEvening { BedtimeRecommendationCard(recommendation: bedtimeRecommendation) } else { @@ -131,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() } + } } } diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index 489b5ca..8063ad4 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,57 @@ 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, so hop to the main + // actor to touch `errorMessage`/`loadSleepData`. Always call + // `completionHandler()` so HealthKit releases its background assertion and + // stops retrying the notification. + Task { @MainActor in + defer { completionHandler() } + guard let self else { return } + if let error { + self.errorMessage = "Sleep data observer error: \(error.localizedDescription)" + return + } + do { + try await self.loadSleepData() + } catch { + self.errorMessage = "Failed to refresh sleep data: \(error.localizedDescription)" + } + } + } + + 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() 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..d721814 --- /dev/null +++ b/Bedtime/Bedtime/Models/SleepInsights.swift @@ -0,0 +1,277 @@ +// +// 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 motivator windows (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 + + /// Longest lookback where cumulative balance is still non-negative. + static func selectCongratulation(from snapshots: [SleepWindowBalance]) -> SleepWindowBalance? { + 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 + } + + // 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..