From b08fe3d3f7f41ed50300b5f7de1a836c1b3dcecf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 30 Jun 2026 20:51:11 +0000 Subject: [PATCH 1/8] Replace max sleep hours with earliest reasonable bedtime Adds earliest reasonable bedtime setting derived from wake time, migrates from the legacy max-hours value, and recovers from incompatible SwiftData stores on startup. Removes the minimum sleep hours setting and floors ahead-of-goal recommendations at the sleep goal instead. Co-authored-by: Greg --- Bedtime/Bedtime/BedtimeApp.swift | 34 ++++++++-- Bedtime/Bedtime/ContentView.swift | 4 +- Bedtime/Bedtime/Models/UserPreferences.swift | 71 +++++++++++++++++++- Bedtime/Bedtime/Models/ViewModel.swift | 9 ++- Bedtime/Bedtime/Views/SettingsView.swift | 41 +++++------ 5 files changed, 119 insertions(+), 40 deletions(-) diff --git a/Bedtime/Bedtime/BedtimeApp.swift b/Bedtime/Bedtime/BedtimeApp.swift index 5288669..36b4b7e 100644 --- a/Bedtime/Bedtime/BedtimeApp.swift +++ b/Bedtime/Bedtime/BedtimeApp.swift @@ -11,7 +11,16 @@ import HealthKit @main struct BedtimeApp: App { - var sharedModelContainer: ModelContainer = { + var sharedModelContainer: ModelContainer = BedtimeApp.makeModelContainer() + + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(sharedModelContainer) + } + + private static func makeModelContainer() -> ModelContainer { let schema = Schema([ UserPreferences.self, ]) @@ -20,14 +29,25 @@ struct BedtimeApp: App { do { return try ModelContainer(for: schema, configurations: [modelConfiguration]) } catch { - fatalError("Could not create ModelContainer: \(error)") + // A prior schema change may have left an incompatible store on disk. + removePersistedStore() + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } } - }() + } - var body: some Scene { - WindowGroup { - ContentView() + private static func removePersistedStore() { + let fileManager = FileManager.default + guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return + } + + for name in ["default.store", "default.store-shm", "default.store-wal"] { + let url = appSupport.appendingPathComponent(name) + try? fileManager.removeItem(at: url) } - .modelContainer(sharedModelContainer) } } diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 5665380..681a947 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -32,6 +32,7 @@ struct ContentView: View { private var userPreferences: UserPreferences { if let existing = preferences.first { + existing.migrateBedtimeLimitIfNeeded() return existing } else { let new = UserPreferences() @@ -53,8 +54,7 @@ struct ContentView: View { wakeTime: userPreferences.wakeTime, sleepGoal: userPreferences.sleepGoalHours, sleepBank: sleepBank, - maxSleepHours: userPreferences.maxSleepHoursPerNight, - minSleepHours: userPreferences.minSleepHoursPerNight + maxSleepHours: userPreferences.effectiveMaxSleepHours ) } diff --git a/Bedtime/Bedtime/Models/UserPreferences.swift b/Bedtime/Bedtime/Models/UserPreferences.swift index 29009a0..d83af1e 100644 --- a/Bedtime/Bedtime/Models/UserPreferences.swift +++ b/Bedtime/Bedtime/Models/UserPreferences.swift @@ -14,14 +14,18 @@ final class UserPreferences { var wakeTime: Date var sleepBankDays: Int var lastUpdated: Date + /// Legacy limit kept for SwiftData schema compatibility and migration. var maxSleepHoursPerNight: Double + /// Preferred bedtime floor; migrated from `maxSleepHoursPerNight` when nil. + var earliestReasonableBedtime: Date? var minSleepHoursPerNight: Double - + init( sleepGoalHours: Double = 8.0, wakeTime: Date = Calendar.current.date(from: DateComponents(hour: 7, minute: 0)) ?? Date(), sleepBankDays: Int = 7, maxSleepHoursPerNight: Double = 10, + earliestReasonableBedtime: Date? = nil, minSleepHoursPerNight: Double = 5 ) { self.sleepGoalHours = sleepGoalHours @@ -30,5 +34,70 @@ final class UserPreferences { self.lastUpdated = Date() self.maxSleepHoursPerNight = maxSleepHoursPerNight self.minSleepHoursPerNight = minSleepHoursPerNight + self.earliestReasonableBedtime = earliestReasonableBedtime + ?? Self.earliestBedtime(maxSleepHours: maxSleepHoursPerNight, wakeTime: wakeTime) + } + + /// Earliest bedtime used by settings and recommendations. + var resolvedEarliestBedtime: Date { + earliestReasonableBedtime + ?? Self.earliestBedtime(maxSleepHours: maxSleepHoursPerNight, wakeTime: wakeTime) + } + + /// Longest sleep window allowed before hitting the earliest reasonable bedtime. + var effectiveMaxSleepHours: Double { + Self.maxSleepHours(earliestBedtime: resolvedEarliestBedtime, wakeTime: wakeTime) + } + + /// One-time migration for stores that predate `earliestReasonableBedtime`. + func migrateBedtimeLimitIfNeeded() { + guard earliestReasonableBedtime == nil else { return } + earliestReasonableBedtime = Self.earliestBedtime( + maxSleepHours: maxSleepHoursPerNight, + wakeTime: wakeTime + ) + } + + static func maxSleepHours( + earliestBedtime: Date, + wakeTime: Date, + calendar: Calendar = .current + ) -> Double { + let wakeMinutes = minutesSinceMidnight(wakeTime, calendar: calendar) + let earliestMinutes = minutesSinceMidnight(earliestBedtime, calendar: calendar) + + let sleepMinutes: Int + if earliestMinutes > wakeMinutes { + sleepMinutes = (24 * 60 - earliestMinutes) + wakeMinutes + } else { + sleepMinutes = wakeMinutes - earliestMinutes + } + + return Double(sleepMinutes) / 60.0 + } + + static func earliestBedtime( + maxSleepHours: Double, + wakeTime: Date, + calendar: Calendar = .current + ) -> Date { + let wakeMinutes = minutesSinceMidnight(wakeTime, calendar: calendar) + let sleepMinutes = Int((maxSleepHours * 60).rounded()) + var earliestMinutes = wakeMinutes - sleepMinutes + if earliestMinutes < 0 { + earliestMinutes += 24 * 60 + } + + return calendar.date( + from: DateComponents( + hour: earliestMinutes / 60, + minute: earliestMinutes % 60 + ) + ) ?? wakeTime + } + + private static func minutesSinceMidnight(_ date: Date, calendar: Calendar) -> Int { + let components = calendar.dateComponents([.hour, .minute], from: date) + return (components.hour ?? 0) * 60 + (components.minute ?? 0) } } diff --git a/Bedtime/Bedtime/Models/ViewModel.swift b/Bedtime/Bedtime/Models/ViewModel.swift index 2d83743..d9c1a29 100644 --- a/Bedtime/Bedtime/Models/ViewModel.swift +++ b/Bedtime/Bedtime/Models/ViewModel.swift @@ -46,8 +46,7 @@ class ViewModel { wakeTime: Date, sleepGoal: Double, sleepBank: SleepBank, - maxSleepHours: Double, - minSleepHours: Double + maxSleepHours: Double ) -> BedtimeRecommendation { let calendar = Calendar.current @@ -62,12 +61,12 @@ class ViewModel { } else if totalSleepNeeded > maxSleepHours { totalSleepNeeded = maxSleepHours reason = "You can't catch up in one night, so just get as much as possible." - } else if totalSleepNeeded < minSleepHours { - totalSleepNeeded = minSleepHours - reason = "You're way ahead!" } 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." + } else if totalSleepNeeded < sleepGoal { + totalSleepNeeded = sleepGoal + reason = "You're ahead of the game! Aim for at least \(String(format: "%.1f", sleepGoal)) hours tonight." } else { reason = "You're ahead of the game! Aim for at least \(String(format: "%.1f", sleepGoal)) hours tonight." } diff --git a/Bedtime/Bedtime/Views/SettingsView.swift b/Bedtime/Bedtime/Views/SettingsView.swift index 0ecebbc..fd21dc9 100644 --- a/Bedtime/Bedtime/Views/SettingsView.swift +++ b/Bedtime/Bedtime/Views/SettingsView.swift @@ -28,6 +28,13 @@ struct SettingsView: View { @State private var debugMessage: String? #endif + private var earliestBedtimeBinding: Binding { + Binding( + get: { preferences.resolvedEarliestBedtime }, + set: { preferences.earliestReasonableBedtime = $0 } + ) + } + var body: some View { NavigationStack { Form { @@ -76,31 +83,15 @@ struct SettingsView: View { } Section("Sleep Limits") { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Max sleep hours per night") - Spacer() - Text("\(String(format: "%.0f", preferences.maxSleepHoursPerNight)) hours") - .foregroundColor(.secondary) - } - - Slider(value: $preferences.maxSleepHoursPerNight, in: 8...16, step: 1) { - Text("Max") - } - .accentColor(.blue) - - HStack { - Text("Min sleep hours per night") - Spacer() - Text("\(String(format: "%.0f", preferences.minSleepHoursPerNight)) hours") - .foregroundColor(.secondary) - } - - Slider(value: $preferences.minSleepHoursPerNight, in: 2...10, step: 1) { - Text("Min") - } - .accentColor(.blue) - } + DatePicker( + "Earliest reasonable bedtime", + selection: earliestBedtimeBinding, + displayedComponents: .hourAndMinute + ) + + Text("Won't recommend sleeping before this time — up to \(String(format: "%.1f", preferences.effectiveMaxSleepHours)) hours before your wake time.") + .font(.caption) + .foregroundColor(.secondary) } Section("Data Sources") { From f7c50159ffb514e3335d8a0e3c51de1b12dcf990 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 01:15:37 +0000 Subject: [PATCH 2/8] Drop legacy max-hours field and migration workarounds Everyone is on earliest reasonable bedtime now. Removes maxSleepHoursPerNight, minSleepHoursPerNight, runtime migration, and store-deletion recovery from ModelContainer setup. Co-authored-by: Greg --- Bedtime/Bedtime/BedtimeApp.swift | 34 +++---------- Bedtime/Bedtime/ContentView.swift | 1 - Bedtime/Bedtime/Models/UserPreferences.swift | 50 ++------------------ Bedtime/Bedtime/Views/SettingsView.swift | 5 +- 4 files changed, 11 insertions(+), 79 deletions(-) diff --git a/Bedtime/Bedtime/BedtimeApp.swift b/Bedtime/Bedtime/BedtimeApp.swift index 36b4b7e..5288669 100644 --- a/Bedtime/Bedtime/BedtimeApp.swift +++ b/Bedtime/Bedtime/BedtimeApp.swift @@ -11,16 +11,7 @@ import HealthKit @main struct BedtimeApp: App { - var sharedModelContainer: ModelContainer = BedtimeApp.makeModelContainer() - - var body: some Scene { - WindowGroup { - ContentView() - } - .modelContainer(sharedModelContainer) - } - - private static func makeModelContainer() -> ModelContainer { + var sharedModelContainer: ModelContainer = { let schema = Schema([ UserPreferences.self, ]) @@ -29,25 +20,14 @@ struct BedtimeApp: App { do { return try ModelContainer(for: schema, configurations: [modelConfiguration]) } catch { - // A prior schema change may have left an incompatible store on disk. - removePersistedStore() - do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) - } catch { - fatalError("Could not create ModelContainer: \(error)") - } - } - } - - private static func removePersistedStore() { - let fileManager = FileManager.default - guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { - return + fatalError("Could not create ModelContainer: \(error)") } + }() - for name in ["default.store", "default.store-shm", "default.store-wal"] { - let url = appSupport.appendingPathComponent(name) - try? fileManager.removeItem(at: url) + var body: some Scene { + WindowGroup { + ContentView() } + .modelContainer(sharedModelContainer) } } diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 681a947..d229158 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -32,7 +32,6 @@ struct ContentView: View { private var userPreferences: UserPreferences { if let existing = preferences.first { - existing.migrateBedtimeLimitIfNeeded() return existing } else { let new = UserPreferences() diff --git a/Bedtime/Bedtime/Models/UserPreferences.swift b/Bedtime/Bedtime/Models/UserPreferences.swift index d83af1e..94a5836 100644 --- a/Bedtime/Bedtime/Models/UserPreferences.swift +++ b/Bedtime/Bedtime/Models/UserPreferences.swift @@ -14,48 +14,24 @@ final class UserPreferences { var wakeTime: Date var sleepBankDays: Int var lastUpdated: Date - /// Legacy limit kept for SwiftData schema compatibility and migration. - var maxSleepHoursPerNight: Double - /// Preferred bedtime floor; migrated from `maxSleepHoursPerNight` when nil. - var earliestReasonableBedtime: Date? - var minSleepHoursPerNight: Double + var earliestReasonableBedtime: Date init( sleepGoalHours: Double = 8.0, wakeTime: Date = Calendar.current.date(from: DateComponents(hour: 7, minute: 0)) ?? Date(), sleepBankDays: Int = 7, - maxSleepHoursPerNight: Double = 10, - earliestReasonableBedtime: Date? = nil, - minSleepHoursPerNight: Double = 5 + earliestReasonableBedtime: Date = Calendar.current.date(from: DateComponents(hour: 21, minute: 0)) ?? Date() ) { self.sleepGoalHours = sleepGoalHours self.wakeTime = wakeTime self.sleepBankDays = sleepBankDays self.lastUpdated = Date() - self.maxSleepHoursPerNight = maxSleepHoursPerNight - self.minSleepHoursPerNight = minSleepHoursPerNight self.earliestReasonableBedtime = earliestReasonableBedtime - ?? Self.earliestBedtime(maxSleepHours: maxSleepHoursPerNight, wakeTime: wakeTime) - } - - /// Earliest bedtime used by settings and recommendations. - var resolvedEarliestBedtime: Date { - earliestReasonableBedtime - ?? Self.earliestBedtime(maxSleepHours: maxSleepHoursPerNight, wakeTime: wakeTime) } /// Longest sleep window allowed before hitting the earliest reasonable bedtime. var effectiveMaxSleepHours: Double { - Self.maxSleepHours(earliestBedtime: resolvedEarliestBedtime, wakeTime: wakeTime) - } - - /// One-time migration for stores that predate `earliestReasonableBedtime`. - func migrateBedtimeLimitIfNeeded() { - guard earliestReasonableBedtime == nil else { return } - earliestReasonableBedtime = Self.earliestBedtime( - maxSleepHours: maxSleepHoursPerNight, - wakeTime: wakeTime - ) + Self.maxSleepHours(earliestBedtime: earliestReasonableBedtime, wakeTime: wakeTime) } static func maxSleepHours( @@ -76,26 +52,6 @@ final class UserPreferences { return Double(sleepMinutes) / 60.0 } - static func earliestBedtime( - maxSleepHours: Double, - wakeTime: Date, - calendar: Calendar = .current - ) -> Date { - let wakeMinutes = minutesSinceMidnight(wakeTime, calendar: calendar) - let sleepMinutes = Int((maxSleepHours * 60).rounded()) - var earliestMinutes = wakeMinutes - sleepMinutes - if earliestMinutes < 0 { - earliestMinutes += 24 * 60 - } - - return calendar.date( - from: DateComponents( - hour: earliestMinutes / 60, - minute: earliestMinutes % 60 - ) - ) ?? wakeTime - } - private static func minutesSinceMidnight(_ date: Date, calendar: Calendar) -> Int { let components = calendar.dateComponents([.hour, .minute], from: date) return (components.hour ?? 0) * 60 + (components.minute ?? 0) diff --git a/Bedtime/Bedtime/Views/SettingsView.swift b/Bedtime/Bedtime/Views/SettingsView.swift index fd21dc9..5de5d9b 100644 --- a/Bedtime/Bedtime/Views/SettingsView.swift +++ b/Bedtime/Bedtime/Views/SettingsView.swift @@ -29,10 +29,7 @@ struct SettingsView: View { #endif private var earliestBedtimeBinding: Binding { - Binding( - get: { preferences.resolvedEarliestBedtime }, - set: { preferences.earliestReasonableBedtime = $0 } - ) + $preferences.earliestReasonableBedtime } var body: some View { From 5e49fe9e9481394036f0ae38cc0a70df66e5a106 Mon Sep 17 00:00:00 2001 From: Greg Date: Fri, 3 Jul 2026 17:57:57 -0700 Subject: [PATCH 3/8] dst support --- Bedtime/Bedtime/Models/UserPreferences.swift | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Bedtime/Bedtime/Models/UserPreferences.swift b/Bedtime/Bedtime/Models/UserPreferences.swift index 94a5836..78701f7 100644 --- a/Bedtime/Bedtime/Models/UserPreferences.swift +++ b/Bedtime/Bedtime/Models/UserPreferences.swift @@ -34,10 +34,44 @@ final class UserPreferences { Self.maxSleepHours(earliestBedtime: earliestReasonableBedtime, wakeTime: wakeTime) } + /// Real hours between the earliest reasonable bedtime and wake time for the + /// night ending at the next wake from `referenceDate`. Anchoring on the wake + /// (forward from now) then searching backward for the bedtime keeps the + /// window correct even when the app is opened mid-sleep, and lets `Calendar` + /// absorb the ±1h of a DST transition instead of assuming a 24h day. static func maxSleepHours( earliestBedtime: Date, wakeTime: Date, + referenceDate: Date = Date(), calendar: Calendar = .current + ) -> Double { + let wakeComponents = calendar.dateComponents([.hour, .minute], from: wakeTime) + let bedComponents = calendar.dateComponents([.hour, .minute], from: earliestBedtime) + + guard + let wake = calendar.nextDate( + after: referenceDate, + matching: wakeComponents, + matchingPolicy: .strict + ), + let bed = calendar.nextDate( + after: wake, + matching: bedComponents, + matchingPolicy: .strict, + direction: .backward + ) + else { + // Fall back to a nominal wall-clock window if a match can't be found. + return nominalMaxSleepHours(earliestBedtime: earliestBedtime, wakeTime: wakeTime, calendar: calendar) + } + + return wake.timeIntervalSince(bed) / 3600.0 + } + + private static func nominalMaxSleepHours( + earliestBedtime: Date, + wakeTime: Date, + calendar: Calendar ) -> Double { let wakeMinutes = minutesSinceMidnight(wakeTime, calendar: calendar) let earliestMinutes = minutesSinceMidnight(earliestBedtime, calendar: calendar) From 0a376cf83f3e4403ac131195ce60bc7ce0e3498a Mon Sep 17 00:00:00 2001 From: Greg Date: Fri, 3 Jul 2026 18:25:33 -0700 Subject: [PATCH 4/8] use nominal for settings --- Bedtime/Bedtime/Models/UserPreferences.swift | 16 ++++++++++++---- Bedtime/Bedtime/Views/SettingsView.swift | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Bedtime/Bedtime/Models/UserPreferences.swift b/Bedtime/Bedtime/Models/UserPreferences.swift index 78701f7..edd4d1a 100644 --- a/Bedtime/Bedtime/Models/UserPreferences.swift +++ b/Bedtime/Bedtime/Models/UserPreferences.swift @@ -29,11 +29,19 @@ final class UserPreferences { self.earliestReasonableBedtime = earliestReasonableBedtime } - /// Longest sleep window allowed before hitting the earliest reasonable bedtime. + /// Real hours available on the current night — DST-aware, for the main-screen + /// recommendation where "how much can I actually get tonight" is what matters. var effectiveMaxSleepHours: Double { Self.maxSleepHours(earliestBedtime: earliestReasonableBedtime, wakeTime: wakeTime) } + /// Nominal wall-clock length of the sleep window (a stable 10h for a 9pm–7am + /// schedule, ignoring DST). For settings/schedule display, where a value that + /// wobbles ±1h twice a year would just be confusing. + var nominalMaxSleepHours: Double { + Self.nominalWindowHours(earliestBedtime: earliestReasonableBedtime, wakeTime: wakeTime) + } + /// Real hours between the earliest reasonable bedtime and wake time for the /// night ending at the next wake from `referenceDate`. Anchoring on the wake /// (forward from now) then searching backward for the bedtime keeps the @@ -62,16 +70,16 @@ final class UserPreferences { ) else { // Fall back to a nominal wall-clock window if a match can't be found. - return nominalMaxSleepHours(earliestBedtime: earliestBedtime, wakeTime: wakeTime, calendar: calendar) + return nominalWindowHours(earliestBedtime: earliestBedtime, wakeTime: wakeTime, calendar: calendar) } return wake.timeIntervalSince(bed) / 3600.0 } - private static func nominalMaxSleepHours( + static func nominalWindowHours( earliestBedtime: Date, wakeTime: Date, - calendar: Calendar + calendar: Calendar = .current ) -> Double { let wakeMinutes = minutesSinceMidnight(wakeTime, calendar: calendar) let earliestMinutes = minutesSinceMidnight(earliestBedtime, calendar: calendar) diff --git a/Bedtime/Bedtime/Views/SettingsView.swift b/Bedtime/Bedtime/Views/SettingsView.swift index 5de5d9b..c504a49 100644 --- a/Bedtime/Bedtime/Views/SettingsView.swift +++ b/Bedtime/Bedtime/Views/SettingsView.swift @@ -86,7 +86,7 @@ struct SettingsView: View { displayedComponents: .hourAndMinute ) - Text("Won't recommend sleeping before this time — up to \(String(format: "%.1f", preferences.effectiveMaxSleepHours)) hours before your wake time.") + Text("Won't recommend sleeping before this time — allowing up to \(String(format: "%.1f", preferences.nominalMaxSleepHours)) hours per night based on your wake time (aside from DST adjustments).") .font(.caption) .foregroundColor(.secondary) } From fa8987e6e6ada6446bf9fd92daed010f539be0d5 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 5 Jul 2026 13:30:09 -0700 Subject: [PATCH 5/8] move out --- Bedtime/Bedtime/ContentView.swift | 4 +- Bedtime/Bedtime/Models/UserPreferences.swift | 44 ++----------------- Bedtime/Bedtime/Models/ViewModel.swift | 45 +++++++++++++++++++- 3 files changed, 48 insertions(+), 45 deletions(-) diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index d229158..a75b734 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -51,9 +51,9 @@ struct ContentView: View { private var bedtimeRecommendation: BedtimeRecommendation { ViewModel.generateBedtimeRecommendation( wakeTime: userPreferences.wakeTime, + earliestBedtime: userPreferences.earliestReasonableBedtime, sleepGoal: userPreferences.sleepGoalHours, - sleepBank: sleepBank, - maxSleepHours: userPreferences.effectiveMaxSleepHours + sleepBank: sleepBank ) } diff --git a/Bedtime/Bedtime/Models/UserPreferences.swift b/Bedtime/Bedtime/Models/UserPreferences.swift index edd4d1a..c668091 100644 --- a/Bedtime/Bedtime/Models/UserPreferences.swift +++ b/Bedtime/Bedtime/Models/UserPreferences.swift @@ -29,53 +29,15 @@ final class UserPreferences { self.earliestReasonableBedtime = earliestReasonableBedtime } - /// Real hours available on the current night — DST-aware, for the main-screen - /// recommendation where "how much can I actually get tonight" is what matters. - var effectiveMaxSleepHours: Double { - Self.maxSleepHours(earliestBedtime: earliestReasonableBedtime, wakeTime: wakeTime) - } - /// Nominal wall-clock length of the sleep window (a stable 10h for a 9pm–7am /// schedule, ignoring DST). For settings/schedule display, where a value that - /// wobbles ±1h twice a year would just be confusing. + /// wobbles ±1h twice a year would just be confusing. The now-aware, + /// DST-adjusted window lives in `ViewModel.maxSleepHours`, since it depends + /// on the clock rather than on stored config. var nominalMaxSleepHours: Double { Self.nominalWindowHours(earliestBedtime: earliestReasonableBedtime, wakeTime: wakeTime) } - /// Real hours between the earliest reasonable bedtime and wake time for the - /// night ending at the next wake from `referenceDate`. Anchoring on the wake - /// (forward from now) then searching backward for the bedtime keeps the - /// window correct even when the app is opened mid-sleep, and lets `Calendar` - /// absorb the ±1h of a DST transition instead of assuming a 24h day. - static func maxSleepHours( - earliestBedtime: Date, - wakeTime: Date, - referenceDate: Date = Date(), - calendar: Calendar = .current - ) -> Double { - let wakeComponents = calendar.dateComponents([.hour, .minute], from: wakeTime) - let bedComponents = calendar.dateComponents([.hour, .minute], from: earliestBedtime) - - guard - let wake = calendar.nextDate( - after: referenceDate, - matching: wakeComponents, - matchingPolicy: .strict - ), - let bed = calendar.nextDate( - after: wake, - matching: bedComponents, - matchingPolicy: .strict, - direction: .backward - ) - else { - // Fall back to a nominal wall-clock window if a match can't be found. - return nominalWindowHours(earliestBedtime: earliestBedtime, wakeTime: wakeTime, calendar: calendar) - } - - return wake.timeIntervalSince(bed) / 3600.0 - } - static func nominalWindowHours( earliestBedtime: Date, wakeTime: Date, diff --git a/Bedtime/Bedtime/Models/ViewModel.swift b/Bedtime/Bedtime/Models/ViewModel.swift index d9c1a29..bdd71d7 100644 --- a/Bedtime/Bedtime/Models/ViewModel.swift +++ b/Bedtime/Bedtime/Models/ViewModel.swift @@ -42,14 +42,55 @@ class ViewModel { ) } + /// Real hours between the earliest reasonable bedtime and wake time for the + /// night ending at the next wake from `referenceDate`. Anchoring on the wake + /// (forward from now) then searching backward for the bedtime keeps the + /// window correct even when the app is opened mid-sleep, and lets `Calendar` + /// absorb the ±1h of a DST transition instead of assuming a 24h day. + static func maxSleepHours( + earliestBedtime: Date, + wakeTime: Date, + referenceDate: Date = Date(), + calendar: Calendar = .current + ) -> Double { + let wakeComponents = calendar.dateComponents([.hour, .minute], from: wakeTime) + let bedComponents = calendar.dateComponents([.hour, .minute], from: earliestBedtime) + + guard + let wake = calendar.nextDate( + after: referenceDate, + matching: wakeComponents, + matchingPolicy: .strict + ), + let bed = calendar.nextDate( + after: wake, + matching: bedComponents, + matchingPolicy: .strict, + direction: .backward + ) + else { + // Fall back to a nominal wall-clock window if a match can't be found. + return UserPreferences.nominalWindowHours(earliestBedtime: earliestBedtime, wakeTime: wakeTime, calendar: calendar) + } + + return wake.timeIntervalSince(bed) / 3600.0 + } + static func generateBedtimeRecommendation( wakeTime: Date, + earliestBedtime: Date, sleepGoal: Double, sleepBank: SleepBank, - maxSleepHours: Double + referenceDate: Date = Date() ) -> BedtimeRecommendation { let calendar = Calendar.current - + let maxSleepHours = maxSleepHours( + earliestBedtime: earliestBedtime, + wakeTime: wakeTime, + referenceDate: referenceDate, + calendar: calendar + ) + // Calculate how much sleep we need tonight // If we're in debt, we need extra sleep to catch up var totalSleepNeeded = sleepGoal - sleepBank.currentBalance From 2fb951bcfb65377e926e25d275dac82fd737e059 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 5 Jul 2026 13:32:20 -0700 Subject: [PATCH 6/8] move to utility --- Bedtime/Bedtime/Models/UserPreferences.swift | 32 +-------- Bedtime/Bedtime/Models/ViewModel.swift | 36 +--------- Bedtime/Bedtime/Utils/SleepWindow.swift | 73 ++++++++++++++++++++ 3 files changed, 77 insertions(+), 64 deletions(-) create mode 100644 Bedtime/Bedtime/Utils/SleepWindow.swift diff --git a/Bedtime/Bedtime/Models/UserPreferences.swift b/Bedtime/Bedtime/Models/UserPreferences.swift index c668091..92be957 100644 --- a/Bedtime/Bedtime/Models/UserPreferences.swift +++ b/Bedtime/Bedtime/Models/UserPreferences.swift @@ -29,35 +29,9 @@ final class UserPreferences { self.earliestReasonableBedtime = earliestReasonableBedtime } - /// Nominal wall-clock length of the sleep window (a stable 10h for a 9pm–7am - /// schedule, ignoring DST). For settings/schedule display, where a value that - /// wobbles ±1h twice a year would just be confusing. The now-aware, - /// DST-adjusted window lives in `ViewModel.maxSleepHours`, since it depends - /// on the clock rather than on stored config. + /// Convenience accessor for the nominal sleep-window length from this + /// schedule's stored times. Now-aware/DST math lives in `SleepWindow`. var nominalMaxSleepHours: Double { - Self.nominalWindowHours(earliestBedtime: earliestReasonableBedtime, wakeTime: wakeTime) - } - - static func nominalWindowHours( - earliestBedtime: Date, - wakeTime: Date, - calendar: Calendar = .current - ) -> Double { - let wakeMinutes = minutesSinceMidnight(wakeTime, calendar: calendar) - let earliestMinutes = minutesSinceMidnight(earliestBedtime, calendar: calendar) - - let sleepMinutes: Int - if earliestMinutes > wakeMinutes { - sleepMinutes = (24 * 60 - earliestMinutes) + wakeMinutes - } else { - sleepMinutes = wakeMinutes - earliestMinutes - } - - return Double(sleepMinutes) / 60.0 - } - - private static func minutesSinceMidnight(_ date: Date, calendar: Calendar) -> Int { - let components = calendar.dateComponents([.hour, .minute], from: date) - return (components.hour ?? 0) * 60 + (components.minute ?? 0) + SleepWindow.nominalWindowHours(earliestBedtime: earliestReasonableBedtime, wakeTime: wakeTime) } } diff --git a/Bedtime/Bedtime/Models/ViewModel.swift b/Bedtime/Bedtime/Models/ViewModel.swift index bdd71d7..1273271 100644 --- a/Bedtime/Bedtime/Models/ViewModel.swift +++ b/Bedtime/Bedtime/Models/ViewModel.swift @@ -42,40 +42,6 @@ class ViewModel { ) } - /// Real hours between the earliest reasonable bedtime and wake time for the - /// night ending at the next wake from `referenceDate`. Anchoring on the wake - /// (forward from now) then searching backward for the bedtime keeps the - /// window correct even when the app is opened mid-sleep, and lets `Calendar` - /// absorb the ±1h of a DST transition instead of assuming a 24h day. - static func maxSleepHours( - earliestBedtime: Date, - wakeTime: Date, - referenceDate: Date = Date(), - calendar: Calendar = .current - ) -> Double { - let wakeComponents = calendar.dateComponents([.hour, .minute], from: wakeTime) - let bedComponents = calendar.dateComponents([.hour, .minute], from: earliestBedtime) - - guard - let wake = calendar.nextDate( - after: referenceDate, - matching: wakeComponents, - matchingPolicy: .strict - ), - let bed = calendar.nextDate( - after: wake, - matching: bedComponents, - matchingPolicy: .strict, - direction: .backward - ) - else { - // Fall back to a nominal wall-clock window if a match can't be found. - return UserPreferences.nominalWindowHours(earliestBedtime: earliestBedtime, wakeTime: wakeTime, calendar: calendar) - } - - return wake.timeIntervalSince(bed) / 3600.0 - } - static func generateBedtimeRecommendation( wakeTime: Date, earliestBedtime: Date, @@ -84,7 +50,7 @@ class ViewModel { referenceDate: Date = Date() ) -> BedtimeRecommendation { let calendar = Calendar.current - let maxSleepHours = maxSleepHours( + let maxSleepHours = SleepWindow.maxSleepHours( earliestBedtime: earliestBedtime, wakeTime: wakeTime, referenceDate: referenceDate, diff --git a/Bedtime/Bedtime/Utils/SleepWindow.swift b/Bedtime/Bedtime/Utils/SleepWindow.swift new file mode 100644 index 0000000..6f3ef74 --- /dev/null +++ b/Bedtime/Bedtime/Utils/SleepWindow.swift @@ -0,0 +1,73 @@ +// +// SleepWindow.swift +// Bedtime +// +// Created by Greg on 10/5/25. +// + +import Foundation + +/// Pure time-interval math for the sleep window between an earliest reasonable +/// bedtime and a wake time. Split into a now-aware (DST-adjusted) measure for the +/// recommendation and a nominal wall-clock measure for schedule display. +struct SleepWindow { + /// Real hours between the earliest reasonable bedtime and wake time for the + /// night ending at the next wake from `referenceDate`. Anchoring on the wake + /// (forward from now) then searching backward for the bedtime keeps the + /// window correct even when the app is opened mid-sleep, and lets `Calendar` + /// absorb the ±1h of a DST transition instead of assuming a 24h day. + static func maxSleepHours( + earliestBedtime: Date, + wakeTime: Date, + referenceDate: Date = Date(), + calendar: Calendar = .current + ) -> Double { + let wakeComponents = calendar.dateComponents([.hour, .minute], from: wakeTime) + let bedComponents = calendar.dateComponents([.hour, .minute], from: earliestBedtime) + + guard + let wake = calendar.nextDate( + after: referenceDate, + matching: wakeComponents, + matchingPolicy: .strict + ), + let bed = calendar.nextDate( + after: wake, + matching: bedComponents, + matchingPolicy: .strict, + direction: .backward + ) + else { + // Fall back to a nominal wall-clock window if a match can't be found. + return nominalWindowHours(earliestBedtime: earliestBedtime, wakeTime: wakeTime, calendar: calendar) + } + + return wake.timeIntervalSince(bed) / 3600.0 + } + + /// Nominal wall-clock length of the sleep window (a stable 10h for a 9pm–7am + /// schedule, ignoring DST). For settings/schedule display, where a value that + /// wobbles ±1h twice a year would just be confusing. + static func nominalWindowHours( + earliestBedtime: Date, + wakeTime: Date, + calendar: Calendar = .current + ) -> Double { + let wakeMinutes = minutesSinceMidnight(wakeTime, calendar: calendar) + let earliestMinutes = minutesSinceMidnight(earliestBedtime, calendar: calendar) + + let sleepMinutes: Int + if earliestMinutes > wakeMinutes { + sleepMinutes = (24 * 60 - earliestMinutes) + wakeMinutes + } else { + sleepMinutes = wakeMinutes - earliestMinutes + } + + return Double(sleepMinutes) / 60.0 + } + + private static func minutesSinceMidnight(_ date: Date, calendar: Calendar) -> Int { + let components = calendar.dateComponents([.hour, .minute], from: date) + return (components.hour ?? 0) * 60 + (components.minute ?? 0) + } +} From 053059e2c6b5dcb9ebd85e95018725da9a82ce44 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 5 Jul 2026 13:37:42 -0700 Subject: [PATCH 7/8] redundant message logic --- Bedtime/Bedtime/Models/ViewModel.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Bedtime/Bedtime/Models/ViewModel.swift b/Bedtime/Bedtime/Models/ViewModel.swift index 1273271..a222f1b 100644 --- a/Bedtime/Bedtime/Models/ViewModel.swift +++ b/Bedtime/Bedtime/Models/ViewModel.swift @@ -71,9 +71,6 @@ class ViewModel { } 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." - } else if totalSleepNeeded < sleepGoal { - totalSleepNeeded = sleepGoal - reason = "You're ahead of the game! Aim for at least \(String(format: "%.1f", sleepGoal)) hours tonight." } else { reason = "You're ahead of the game! Aim for at least \(String(format: "%.1f", sleepGoal)) hours tonight." } From 74fbe150c2aa9d7771e503ada0106e62e3e0bf11 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 5 Jul 2026 13:51:04 -0700 Subject: [PATCH 8/8] per review --- Bedtime/Bedtime/Utils/SleepWindow.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bedtime/Bedtime/Utils/SleepWindow.swift b/Bedtime/Bedtime/Utils/SleepWindow.swift index 6f3ef74..f9c6551 100644 --- a/Bedtime/Bedtime/Utils/SleepWindow.swift +++ b/Bedtime/Bedtime/Utils/SleepWindow.swift @@ -57,7 +57,7 @@ struct SleepWindow { let earliestMinutes = minutesSinceMidnight(earliestBedtime, calendar: calendar) let sleepMinutes: Int - if earliestMinutes > wakeMinutes { + if earliestMinutes >= wakeMinutes { sleepMinutes = (24 * 60 - earliestMinutes) + wakeMinutes } else { sleepMinutes = wakeMinutes - earliestMinutes