Skip to content
19 changes: 18 additions & 1 deletion Bedtime/Bedtime/BedtimeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
}
}()

Expand Down
27 changes: 26 additions & 1 deletion Bedtime/Bedtime/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,7 +45,8 @@ struct ContentView: View {
ViewModel.calculateSleepBank(
sleepSessions: healthKitManager.sleepSessions,
goalHours: userPreferences.sleepGoalHours,
recentDays: userPreferences.sleepBankDays
recentDays: userPreferences.sleepBankDays,
wakeTime: userPreferences.wakeTime
)
}

Expand All @@ -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 {
Expand All @@ -80,6 +94,10 @@ struct ContentView: View {

SleepBankCard(sleepBank: sleepBank)

if let sleepBankInsight {
SleepInsightsCard(insight: sleepBankInsight)
}

if isBeforeEvening {
BedtimeRecommendationCard(recommendation: bedtimeRecommendation)
} else {
Expand Down Expand Up @@ -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() }
}
}
}

Expand Down
61 changes: 61 additions & 0 deletions Bedtime/Bedtime/Models/HealthKitManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class HealthKitManager: ObservableObject {
private let sourcePreferences: SourcePreferences
private var rawSleepSamples: [HKCategorySample] = []
private var cancellables = Set<AnyCancellable>()
/// 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]] = [:]
Expand All @@ -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"])
Expand Down Expand Up @@ -90,6 +99,7 @@ class HealthKitManager: ObservableObject {
do {
try await requestAuthorization()
try await loadSleepData()
startObservingSleepChanges()
} catch {
errorMessage = error.localizedDescription
throw error
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions Bedtime/Bedtime/Models/SleepData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ extension HKCategoryValueSleepAnalysis {
}
}

struct SleepBank {
struct SleepBank: Equatable {
let currentBalance: Double // in hours
let goalHours: Double
let averageHours: Double?
Expand Down Expand Up @@ -118,5 +118,5 @@ struct BedtimeRecommendation {
let recommendedBedtime: Date
let wakeTime: Date
let targetSleepDuration: Double // in hours
let reason: String
let reason: String?
}
Loading