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..6582649 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 @@ -44,7 +45,8 @@ struct ContentView: View { ViewModel.calculateSleepBank( sleepSessions: healthKitManager.sleepSessions, goalHours: userPreferences.sleepGoalHours, - recentDays: userPreferences.sleepBankDays + recentDays: userPreferences.sleepBankDays, + wakeTime: userPreferences.wakeTime ) } @@ -57,6 +59,18 @@ struct ContentView: View { ) } + private var sleepBankInsight: SleepBankInsight? { + SleepInsightsEngine.generateInsight( + sleepSessions: healthKitManager.sleepSessions, + goalHours: userPreferences.sleepGoalHours, + wakeTime: userPreferences.wakeTime, + 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 +94,10 @@ struct ContentView: View { SleepBankCard(sleepBank: sleepBank) + if let sleepBankInsight { + SleepInsightsCard(insight: sleepBankInsight) + } + if isBeforeEvening { BedtimeRecommendationCard(recommendation: bedtimeRecommendation) } else { @@ -131,6 +149,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..a861564 --- /dev/null +++ b/Bedtime/Bedtime/Models/SleepInsights.swift @@ -0,0 +1,284 @@ +// +// 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, + wakeTime: Date, + maxSleepHours: Double + ) -> SleepBankInsight? { + let snapshots = windowBalances( + sleepSessions: sleepSessions, + goalHours: goalHours, + wakeTime: wakeTime + ) + 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, + wakeTime: Date + ) -> [SleepWindowBalance] { + windowRange.map { days in + let bank = ViewModel.calculateSleepBank( + sleepSessions: sleepSessions, + goalHours: goalHours, + recentDays: days, + wakeTime: wakeTime + ) + 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..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 @@ -62,12 +69,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 { + 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 + } } diff --git a/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift b/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift index eeb0374..73b5234 100644 --- a/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift +++ b/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift @@ -57,12 +57,14 @@ struct BedtimeRecommendationCard: View { } } - // Reason text - Text(recommendation.reason) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - .padding(.top, 4) + // Reason text (omitted when the sleep insight card covers it) + if let reason = recommendation.reason { + Text(reason) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + .padding(.top, 4) + } } } } diff --git a/Bedtime/Bedtime/Views/SleepInsightsCard.swift b/Bedtime/Bedtime/Views/SleepInsightsCard.swift new file mode 100644 index 0000000..3d7bb49 --- /dev/null +++ b/Bedtime/Bedtime/Views/SleepInsightsCard.swift @@ -0,0 +1,97 @@ +// +// SleepInsightsCard.swift +// Bedtime +// +// Surfaces auto-selected flattering and motivating sleep-bank windows. +// + +import SwiftUI + +struct SleepInsightsCard: View { + let insight: SleepBankInsight + + private var accentColor: Color { + if insight.congratulationWindow != nil { + return .green + } + return .orange + } + + private var iconName: String { + if insight.congratulationWindow != nil && insight.motivatorWindow?.isAhead == false { + return "sparkles" + } + if insight.congratulationWindow != nil { + return "hand.thumbsup.fill" + } + return "bolt.fill" + } + + var body: some View { + CardComponent { + VStack(alignment: .leading, spacing: 12) { + CardHeader( + icon: iconName, + iconColor: accentColor, + title: "Sleep Insight" + ) + + Text(insight.message) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + +#Preview("Ahead and behind") { + let ahead = SleepWindowBalance( + days: 12, + balance: 0.5, + sleepBank: SleepBank(currentBalance: 0.5, goalHours: 8, averageHours: 8.04) + ) + let behind = SleepWindowBalance( + days: 3, + balance: -0.75, + sleepBank: SleepBank(currentBalance: -0.75, goalHours: 8, averageHours: 7.75) + ) + let insight = SleepBankInsight( + message: SleepInsightsEngine.buildNarrative( + congratulation: ahead, + motivator: behind, + goalHours: 8, + maxSleepHours: 10 + )!, + congratulationWindow: ahead, + motivatorWindow: behind, + motivatorIsCatchable: true + ) + + SleepInsightsCard(insight: insight) + .padding() + .background(Color.backgroundBehindCards) +} + +#Preview("Motivator only") { + let behind = SleepWindowBalance( + days: 5, + balance: -1.25, + sleepBank: SleepBank(currentBalance: -1.25, goalHours: 8, averageHours: 7.75) + ) + let insight = SleepBankInsight( + message: SleepInsightsEngine.buildNarrative( + congratulation: nil, + motivator: behind, + goalHours: 8, + maxSleepHours: 10 + )!, + congratulationWindow: nil, + motivatorWindow: behind, + motivatorIsCatchable: true + ) + + SleepInsightsCard(insight: insight) + .padding() + .background(Color.backgroundBehindCards) +}