Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions Bedtime/Bedtime/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
17 changes: 10 additions & 7 deletions Bedtime/Bedtime/Models/UserPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
15 changes: 9 additions & 6 deletions Bedtime/Bedtime/Models/ViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."
Expand Down
73 changes: 73 additions & 0 deletions Bedtime/Bedtime/Utils/SleepWindow.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
38 changes: 13 additions & 25 deletions Bedtime/Bedtime/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ struct SettingsView: View {
@State private var debugMessage: String?
#endif

private var earliestBedtimeBinding: Binding<Date> {
$preferences.earliestReasonableBedtime
}

var body: some View {
NavigationStack {
Form {
Expand Down Expand Up @@ -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") {
Expand Down