diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 5665380..a75b734 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -51,10 +51,9 @@ struct ContentView: View { private var bedtimeRecommendation: BedtimeRecommendation { ViewModel.generateBedtimeRecommendation( wakeTime: userPreferences.wakeTime, + earliestBedtime: userPreferences.earliestReasonableBedtime, sleepGoal: userPreferences.sleepGoalHours, - sleepBank: sleepBank, - maxSleepHours: userPreferences.maxSleepHoursPerNight, - minSleepHours: userPreferences.minSleepHoursPerNight + sleepBank: sleepBank ) } diff --git a/Bedtime/Bedtime/Models/UserPreferences.swift b/Bedtime/Bedtime/Models/UserPreferences.swift index 29009a0..92be957 100644 --- a/Bedtime/Bedtime/Models/UserPreferences.swift +++ b/Bedtime/Bedtime/Models/UserPreferences.swift @@ -14,21 +14,24 @@ final class UserPreferences { var wakeTime: Date var sleepBankDays: Int var lastUpdated: Date - var maxSleepHoursPerNight: Double - 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, - 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 + } + + /// Convenience accessor for the nominal sleep-window length from this + /// schedule's stored times. Now-aware/DST math lives in `SleepWindow`. + var nominalMaxSleepHours: Double { + SleepWindow.nominalWindowHours(earliestBedtime: earliestReasonableBedtime, wakeTime: wakeTime) } } diff --git a/Bedtime/Bedtime/Models/ViewModel.swift b/Bedtime/Bedtime/Models/ViewModel.swift index 2d83743..a222f1b 100644 --- a/Bedtime/Bedtime/Models/ViewModel.swift +++ b/Bedtime/Bedtime/Models/ViewModel.swift @@ -44,13 +44,19 @@ class ViewModel { static func generateBedtimeRecommendation( wakeTime: Date, + earliestBedtime: Date, sleepGoal: Double, sleepBank: SleepBank, - maxSleepHours: Double, - minSleepHours: Double + referenceDate: Date = Date() ) -> BedtimeRecommendation { let calendar = Calendar.current - + let maxSleepHours = SleepWindow.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 @@ -62,9 +68,6 @@ 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." diff --git a/Bedtime/Bedtime/Utils/SleepWindow.swift b/Bedtime/Bedtime/Utils/SleepWindow.swift new file mode 100644 index 0000000..f9c6551 --- /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) + } +} diff --git a/Bedtime/Bedtime/Views/SettingsView.swift b/Bedtime/Bedtime/Views/SettingsView.swift index 0ecebbc..c504a49 100644 --- a/Bedtime/Bedtime/Views/SettingsView.swift +++ b/Bedtime/Bedtime/Views/SettingsView.swift @@ -28,6 +28,10 @@ struct SettingsView: View { @State private var debugMessage: String? #endif + private var earliestBedtimeBinding: Binding { + $preferences.earliestReasonableBedtime + } + var body: some View { NavigationStack { Form { @@ -76,31 +80,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 — allowing up to \(String(format: "%.1f", preferences.nominalMaxSleepHours)) hours per night based on your wake time (aside from DST adjustments).") + .font(.caption) + .foregroundColor(.secondary) } Section("Data Sources") {