From 70a6380a144d445766c97013d7ce51caf5517169 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 22:25:16 +0000 Subject: [PATCH 1/6] Feature the night underway instead of a 4am clock key lastNightKey bucketed by startOfDay(now - 4h) while sessions bucket by midpoint + 6h, so between midnight and 4am the card looked under yesterday for sleep filed under today: a 2am wake-up showed "No sleep data" while Recent Sleep listed that same session, and the empty key also offered to sync data that was already present. Both now derive the day from one place, SleepDay. The card features the sleep day underway, so partial sleep shows as it accumulates, falling back to the previous night only in the small hours when nothing is recorded yet. After that an empty night stays empty so the sync prompt can appear. Co-authored-by: Greg --- Bedtime/Bedtime/Constants.swift | 3 +++ Bedtime/Bedtime/ContentView.swift | 34 +++++++++++++++++++------- Bedtime/Bedtime/Models/SleepData.swift | 4 +-- Bedtime/Bedtime/Utils/SleepDay.swift | 28 +++++++++++++++++++++ 4 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 Bedtime/Bedtime/Utils/SleepDay.swift diff --git a/Bedtime/Bedtime/Constants.swift b/Bedtime/Bedtime/Constants.swift index 654cd89..b4c74be 100644 --- a/Bedtime/Bedtime/Constants.swift +++ b/Bedtime/Bedtime/Constants.swift @@ -12,6 +12,9 @@ class Constants { static let iconWidth: CGFloat = 30 static let cardHeaderSpacing: CGFloat = 12 static let sleepHistoryDays = 30 + /// Before this hour, a night with no sleep recorded yet falls back to the night + /// before: sleep may still be in progress, or the tracker may not have synced. + static let smallHoursEndHour = 4 /// Only suggest opening source apps that have written sleep data within this window. static let recentSourceAppLookbackDays = 7 /// Allowed lookback for sleep-bank / insight window selection (Settings slider, insights, list handle). diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 81cfafb..2145a9a 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -25,19 +25,35 @@ struct ContentView: View { _healthKitManager = StateObject(wrappedValue: HealthKitManager(sourcePreferences: sourcePrefs)) } - private var lastNightKey: Date { + /// The night the summary card features: the sleep day currently underway, so waking + /// briefly at 2am shows tonight's sleep so far rather than yesterday's total. + /// + /// In the small hours that day can have nothing recorded yet — sleep is still in + /// progress, or the tracker hasn't synced — so it falls back to the night before. + /// Later in the day an empty night stays empty, leaving the no-data state free to + /// prompt a sync instead of resurrecting older sleep. + private var featuredNight: Date { + let now = Date() let calendar = Calendar.current - return calendar.startOfDay( - for: calendar.date(byAdding: .hour, value: -4, to: Date()) ?? Date() - ) + let nightUnderway = SleepDay.containing(now, calendar: calendar) + + guard + healthKitManager.sleepSessions[nightUnderway] == nil, + calendar.component(.hour, from: now) < Constants.smallHoursEndHour, + let previousNight = SleepDay.previous(before: nightUnderway, calendar: calendar) + else { + return nightUnderway + } + + return previousNight } - var lastNightData: [SleepSession]? { - healthKitManager.sleepSessions[lastNightKey] + var featuredNightData: [SleepSession]? { + healthKitManager.sleepSessions[featuredNight] } private var recentSourceAppLinks: [SleepSourceAppLink] { - guard healthKitManager.allSleepSessions[lastNightKey] == nil else { return [] } + guard healthKitManager.allSleepSessions[featuredNight] == nil else { return [] } return ViewModel.recentSourceAppLinks(sleepSessions: healthKitManager.allSleepSessions) } @@ -119,7 +135,7 @@ struct ContentView: View { HealthKitAuthorizationCard(healthKitManager: healthKitManager) case .hasRequested: if isBeforeEvening { - LastNightCard(sleepSessions: lastNightData, + LastNightCard(sleepSessions: featuredNightData, goal: userPreferences.sleepGoalHours, sourceAppLinks: recentSourceAppLinks) } else { @@ -153,7 +169,7 @@ struct ContentView: View { wakeTime: wakeTimeBinding ) } else { - LastNightCard(sleepSessions: lastNightData, + LastNightCard(sleepSessions: featuredNightData, goal: userPreferences.sleepGoalHours, sourceAppLinks: recentSourceAppLinks) } diff --git a/Bedtime/Bedtime/Models/SleepData.swift b/Bedtime/Bedtime/Models/SleepData.swift index 4d16feb..d025d9f 100644 --- a/Bedtime/Bedtime/Models/SleepData.swift +++ b/Bedtime/Bedtime/Models/SleepData.swift @@ -29,9 +29,7 @@ struct SleepSession { } var dateForGrouping: Date { - let midpoint = startDate.addingTimeInterval(duration / 2) - let shiftedMidpoint = midpoint.addingTimeInterval(TimeInterval(6 * 60 * 60)) - return Calendar.current.startOfDay(for: shiftedMidpoint) + SleepDay.containing(startDate.addingTimeInterval(duration / 2)) } } diff --git a/Bedtime/Bedtime/Utils/SleepDay.swift b/Bedtime/Bedtime/Utils/SleepDay.swift new file mode 100644 index 0000000..550d249 --- /dev/null +++ b/Bedtime/Bedtime/Utils/SleepDay.swift @@ -0,0 +1,28 @@ +// +// SleepDay.swift +// Bedtime +// + +import Foundation + +/// The calendar day a stretch of sleep is credited to. +/// +/// Sleep that crosses midnight belongs to the day it ends on, so sleep days are cut +/// at 6pm rather than midnight: shifting an instant forward six hours and taking that +/// day's start files an evening bedtime and the morning it leads into under one day. +enum SleepDay { + /// How far an instant is shifted before its day is taken, which puts the boundary + /// between one sleep day and the next at 6pm. + static let shift: TimeInterval = 6 * 60 * 60 + + /// The sleep day `date` belongs to. Applied to a session's midpoint this buckets + /// the session; applied to `now` it gives the sleep day currently underway. + static func containing(_ date: Date, calendar: Calendar = .current) -> Date { + calendar.startOfDay(for: date.addingTimeInterval(shift)) + } + + /// The sleep day before `day`. + static func previous(before day: Date, calendar: Calendar = .current) -> Date? { + calendar.date(byAdding: .day, value: -1, to: day) + } +} From 59e33984968595e9647267a57887d1cdd796df1b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 22:28:00 +0000 Subject: [PATCH 2/6] Extract featured-night selection into SleepDay The rule lived in a ContentView computed property, so it could only be exercised by running the app at the hours in question. Moving it beside the day math it depends on makes it a pure function over the nights that have data, with now and the calendar injectable. Co-authored-by: Greg --- Bedtime/Bedtime/ContentView.swift | 21 +-------------------- Bedtime/Bedtime/Utils/SleepDay.swift | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 2145a9a..7b6ff49 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -25,27 +25,8 @@ struct ContentView: View { _healthKitManager = StateObject(wrappedValue: HealthKitManager(sourcePreferences: sourcePrefs)) } - /// The night the summary card features: the sleep day currently underway, so waking - /// briefly at 2am shows tonight's sleep so far rather than yesterday's total. - /// - /// In the small hours that day can have nothing recorded yet — sleep is still in - /// progress, or the tracker hasn't synced — so it falls back to the night before. - /// Later in the day an empty night stays empty, leaving the no-data state free to - /// prompt a sync instead of resurrecting older sleep. private var featuredNight: Date { - let now = Date() - let calendar = Calendar.current - let nightUnderway = SleepDay.containing(now, calendar: calendar) - - guard - healthKitManager.sleepSessions[nightUnderway] == nil, - calendar.component(.hour, from: now) < Constants.smallHoursEndHour, - let previousNight = SleepDay.previous(before: nightUnderway, calendar: calendar) - else { - return nightUnderway - } - - return previousNight + SleepDay.featured(in: healthKitManager.sleepSessions) } var featuredNightData: [SleepSession]? { diff --git a/Bedtime/Bedtime/Utils/SleepDay.swift b/Bedtime/Bedtime/Utils/SleepDay.swift index 550d249..1a550e4 100644 --- a/Bedtime/Bedtime/Utils/SleepDay.swift +++ b/Bedtime/Bedtime/Utils/SleepDay.swift @@ -25,4 +25,30 @@ enum SleepDay { static func previous(before day: Date, calendar: Calendar = .current) -> Date? { calendar.date(byAdding: .day, value: -1, to: day) } + + /// The sleep day a summary should feature, given the nights that have data. + /// + /// Prefers the day underway, so waking briefly at 2am shows tonight's sleep so far + /// rather than yesterday's total. In the small hours that day can have nothing + /// recorded yet — sleep is still in progress, or the tracker hasn't synced — so it + /// falls back to the night before. Later in the day an empty night stays empty, + /// leaving the no-data state free to prompt a sync instead of resurrecting older + /// sleep. + static func featured( + in nights: [Date: Sessions], + now: Date = Date(), + calendar: Calendar = .current + ) -> Date { + let nightUnderway = containing(now, calendar: calendar) + + guard + nights[nightUnderway] == nil, + calendar.component(.hour, from: now) < Constants.smallHoursEndHour, + let previousNight = previous(before: nightUnderway, calendar: calendar) + else { + return nightUnderway + } + + return previousNight + } } From 826d3168f40af153c9003b8dd66c73c7914b3a70 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 22:31:01 +0000 Subject: [PATCH 3/6] Add unit test target covering sleep day selection Twelve Swift Testing cases pin the 6pm sleep-day boundary, both DST transitions, and each branch of the featured-night rule: small-hours partial sleep, the small-hours fallback, the 4am cutoff, daytime staying empty so the sync prompt can appear, and an evening nap counting as the night underway. All inject a fixed calendar so they don't depend on the machine's time zone. The project file was edited outside Xcode, so it was re-parsed afterward to confirm both targets resolve with no dangling references. Co-authored-by: Greg --- AGENTS.md | 10 +- Bedtime/Bedtime.xcodeproj/project.pbxproj | 126 +++++++++++++++ Bedtime/BedtimeTests/SleepDayTests.swift | 184 ++++++++++++++++++++++ 3 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 Bedtime/BedtimeTests/SleepDayTests.swift diff --git a/AGENTS.md b/AGENTS.md index 2305430..f8ee2c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,10 @@ SwiftUI, SwiftData, and HealthKit and targets iOS 17+. See `README.md` for the p - No third‑party dependencies: there is no Swift Package Manager manifest, no CocoaPods/Carthage, and no package manager. The app uses only Apple system frameworks (`SwiftUI`, `SwiftData`, `HealthKit`, `Combine`, `Foundation`). -- There are currently **no test targets** and **no lint config** (no `.swiftlint.yml` / - `.swift-format`) checked in. +- Unit tests live in `Bedtime/BedtimeTests` (target `BedtimeTests`, Swift Testing). They cover + pure logic only — date/window math and formatting — and inject a fixed calendar rather than + relying on the machine's time zone. There is **no lint config** (no `.swiftlint.yml` / + `.swift-format`) checked in. ## Standard development (macOS + Xcode) @@ -16,7 +18,9 @@ On a macOS machine with Xcode 15+ installed: - Open and run: open `Bedtime/Bedtime.xcodeproj` in Xcode, select the `Bedtime` scheme, and run. - Build from CLI: - `xcodebuild -project Bedtime/Bedtime.xcodeproj -scheme Bedtime -destination 'platform=iOS Simulator,name=iPhone 15' build` + `xcodebuild -project Bedtime/Bedtime.xcodeproj -scheme Bedtime -destination 'platform=iOS Simulator,name=iPhone 15' build` +- Run tests: + `xcodebuild -project Bedtime/Bedtime.xcodeproj -scheme Bedtime -destination 'platform=iOS Simulator,name=iPhone 15' test` - Run HealthKit features in the iOS Simulator. The Simulator starts with no sleep data, so seed it first: in a DEBUG build, use the debug buttons in `SettingsView` (backed by `DebugDataGenerator` / `HealthKitManager.generateFakeSleepData`), which write synthetic sleep‑stage diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index 386fa6d..0d0950d 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -6,8 +6,19 @@ objectVersion = 77; objects = { +/* Begin PBXContainerItemProxy section */ + 37B0000072E91B7E900D8ED8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 373AAED52E91B7E900D8ED84 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 373AAEDC2E91B7E900D8ED84; + remoteInfo = Bedtime; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 373AAEDD2E91B7E900D8ED84 /* Bedger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bedger.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 37B0000012E91B7E900D8ED8 /* BedtimeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BedtimeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -16,6 +27,11 @@ path = Bedtime; sourceTree = ""; }; + 37B0000022E91B7E900D8ED8 /* BedtimeTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = BedtimeTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +42,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 37B0000042E91B7E900D8ED8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -33,6 +56,7 @@ isa = PBXGroup; children = ( 373AAEDF2E91B7E900D8ED84 /* Bedtime */, + 37B0000022E91B7E900D8ED8 /* BedtimeTests */, 373AAEDE2E91B7E900D8ED84 /* Products */, ); sourceTree = ""; @@ -41,6 +65,7 @@ isa = PBXGroup; children = ( 373AAEDD2E91B7E900D8ED84 /* Bedger.app */, + 37B0000012E91B7E900D8ED8 /* BedtimeTests.xctest */, ); name = Products; sourceTree = ""; @@ -70,6 +95,29 @@ productReference = 373AAEDD2E91B7E900D8ED84 /* Bedger.app */; productType = "com.apple.product-type.application"; }; + 37B0000062E91B7E900D8ED8 /* BedtimeTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 37B0000092E91B7E900D8ED8 /* Build configuration list for PBXNativeTarget "BedtimeTests" */; + buildPhases = ( + 37B0000032E91B7E900D8ED8 /* Sources */, + 37B0000042E91B7E900D8ED8 /* Frameworks */, + 37B0000052E91B7E900D8ED8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 37B0000082E91B7E900D8ED8 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 37B0000022E91B7E900D8ED8 /* BedtimeTests */, + ); + name = BedtimeTests; + packageProductDependencies = ( + ); + productName = BedtimeTests; + productReference = 37B0000012E91B7E900D8ED8 /* BedtimeTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -83,6 +131,10 @@ 373AAEDC2E91B7E900D8ED84 = { CreatedOnToolsVersion = 26.0; }; + 37B0000062E91B7E900D8ED8 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = 373AAEDC2E91B7E900D8ED84; + }; }; }; buildConfigurationList = 373AAED82E91B7E900D8ED84 /* Build configuration list for PBXProject "Bedtime" */; @@ -100,6 +152,7 @@ projectRoot = ""; targets = ( 373AAEDC2E91B7E900D8ED84 /* Bedtime */, + 37B0000062E91B7E900D8ED8 /* BedtimeTests */, ); }; /* End PBXProject section */ @@ -112,6 +165,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 37B0000052E91B7E900D8ED8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -122,8 +182,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 37B0000032E91B7E900D8ED8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 37B0000082E91B7E900D8ED8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 373AAEDC2E91B7E900D8ED84 /* Bedtime */; + targetProxy = 37B0000072E91B7E900D8ED8 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 373AAEEC2E91B7EA00D8ED84 /* Debug */ = { isa = XCBuildConfiguration; @@ -322,6 +397,48 @@ }; name = Release; }; + 37B00000A2E91B7E900D8ED8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9J44X82D53; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 0.9.0; + PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Bedger.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Bedger"; + }; + name = Debug; + }; + 37B00000B2E91B7E900D8ED8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9J44X82D53; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 0.9.0; + PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Bedger.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Bedger"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -343,6 +460,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 37B0000092E91B7E900D8ED8 /* Build configuration list for PBXNativeTarget "BedtimeTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 37B00000A2E91B7E900D8ED8 /* Debug */, + 37B00000B2E91B7E900D8ED8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 373AAED52E91B7E900D8ED84 /* Project object */; diff --git a/Bedtime/BedtimeTests/SleepDayTests.swift b/Bedtime/BedtimeTests/SleepDayTests.swift new file mode 100644 index 0000000..9261120 --- /dev/null +++ b/Bedtime/BedtimeTests/SleepDayTests.swift @@ -0,0 +1,184 @@ +// +// SleepDayTests.swift +// BedtimeTests +// + +import Foundation +import Testing + +@testable import Bedger + +/// A fixed zone keeps the 6pm boundary and the DST cases deterministic wherever these +/// run. `SleepDay` takes a calendar for exactly this reason. +private let testCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/New_York")! + return calendar +}() + +private func moment(_ month: Int, _ dayOfMonth: Int, hour: Int, minute: Int = 0) -> Date { + testCalendar.date( + from: DateComponents(year: 2025, month: month, day: dayOfMonth, hour: hour, minute: minute) + )! +} + +/// The bucket key for a calendar day, which is that day's midnight. +private func sleepDay(_ month: Int, _ dayOfMonth: Int) -> Date { + testCalendar.startOfDay(for: moment(month, dayOfMonth, hour: 12)) +} + +@Suite("Sleep day boundaries") +struct SleepDayBoundaryTests { + @Test("A sleep day runs from 6pm to 6pm") + func sleepDayCutAtSixPM() { + #expect(SleepDay.containing(moment(6, 10, hour: 17, minute: 59), calendar: testCalendar) == sleepDay(6, 10)) + #expect(SleepDay.containing(moment(6, 10, hour: 18), calendar: testCalendar) == sleepDay(6, 11)) + } + + @Test("An evening bedtime and the morning it leads into share a day") + func overnightSleepSharesOneDay() { + let bedtime = SleepDay.containing(moment(6, 10, hour: 23), calendar: testCalendar) + let wake = SleepDay.containing(moment(6, 11, hour: 7), calendar: testCalendar) + + #expect(bedtime == sleepDay(6, 11)) + #expect(wake == sleepDay(6, 11)) + } + + @Test("Sleep is credited to the day it ends on") + func sleepCreditedToWakeDay() { + // 10pm–2am: the midpoint lands at midnight, which belongs to the second day. + let midpoint = moment(6, 11, hour: 0) + + #expect(SleepDay.containing(midpoint, calendar: testCalendar) == sleepDay(6, 11)) + } + + @Test("A night spanning spring forward is credited to the morning") + func springForwardNight() { + // 2am jumps to 3am on 9 March 2025, so this night is seven hours long. + let bedtime = moment(3, 8, hour: 23) + let wake = moment(3, 9, hour: 7) + #expect(wake.timeIntervalSince(bedtime) == 7 * 3600) + + let midpoint = bedtime.addingTimeInterval(wake.timeIntervalSince(bedtime) / 2) + + #expect(SleepDay.containing(midpoint, calendar: testCalendar) == sleepDay(3, 9)) + } + + @Test("A night spanning fall back is credited to the morning") + func fallBackNight() { + // 2am repeats on 2 November 2025, so this night is nine hours long. + let bedtime = moment(11, 1, hour: 23) + let wake = moment(11, 2, hour: 7) + #expect(wake.timeIntervalSince(bedtime) == 9 * 3600) + + let midpoint = bedtime.addingTimeInterval(wake.timeIntervalSince(bedtime) / 2) + + #expect(SleepDay.containing(midpoint, calendar: testCalendar) == sleepDay(11, 2)) + } +} + +@Suite("Featured night selection") +struct FeaturedNightTests { + /// Stands in for a night's sessions; only the presence of a key matters here. + private let recorded = ["session"] + + @Test("Waking in the small hours features tonight's sleep so far") + func smallHoursPrefersNightUnderway() { + let nights = [sleepDay(6, 10): recorded, sleepDay(6, 11): recorded] + + let featured = SleepDay.featured( + in: nights, + now: moment(6, 11, hour: 2, minute: 30), + calendar: testCalendar + ) + + #expect(featured == sleepDay(6, 11)) + } + + @Test("The small hours fall back when tonight has nothing recorded yet") + func smallHoursFallsBack() { + let nights = [sleepDay(6, 10): recorded] + + let featured = SleepDay.featured( + in: nights, + now: moment(6, 11, hour: 2, minute: 30), + calendar: testCalendar + ) + + #expect(featured == sleepDay(6, 10)) + } + + @Test("Daytime features the night that just ended") + func daytimeFeaturesCompletedNight() { + let nights = [sleepDay(6, 11): recorded] + + let featured = SleepDay.featured( + in: nights, + now: moment(6, 11, hour: 12), + calendar: testCalendar + ) + + #expect(featured == sleepDay(6, 11)) + } + + @Test("Daytime with no sleep recorded stays empty rather than falling back") + func daytimeDoesNotFallBack() { + let nights = [sleepDay(6, 10): recorded] + + let featured = SleepDay.featured( + in: nights, + now: moment(6, 11, hour: 12), + calendar: testCalendar + ) + + // Selects the empty night, leaving the no-data state free to prompt a sync. + #expect(featured == sleepDay(6, 11)) + #expect(nights[featured] == nil) + } + + @Test("Evening doesn't resurrect last night") + func eveningDoesNotFallBack() { + let nights = [sleepDay(6, 11): recorded] + + let featured = SleepDay.featured( + in: nights, + now: moment(6, 11, hour: 23), + calendar: testCalendar + ) + + #expect(featured == sleepDay(6, 12)) + #expect(nights[featured] == nil) + } + + @Test("An evening nap counts as the night underway") + func eveningNapIsTonight() { + // A 9–11pm nap on the 11th has a midpoint of 10pm, so it buckets to the 12th. + let nights = [sleepDay(6, 11): recorded, sleepDay(6, 12): recorded] + + let featured = SleepDay.featured( + in: nights, + now: moment(6, 11, hour: 23), + calendar: testCalendar + ) + + #expect(featured == sleepDay(6, 12)) + } + + @Test("Falling back stops at the small hours cutoff") + func fallbackStopsAtCutoff() { + let nights = [sleepDay(6, 10): recorded] + let justBefore = SleepDay.featured( + in: nights, + now: moment(6, 11, hour: Constants.smallHoursEndHour - 1, minute: 59), + calendar: testCalendar + ) + let atCutoff = SleepDay.featured( + in: nights, + now: moment(6, 11, hour: Constants.smallHoursEndHour), + calendar: testCalendar + ) + + #expect(justBefore == sleepDay(6, 10)) + #expect(atCutoff == sleepDay(6, 11)) + } +} From 386387e6b5af91776920587d12a92089b1f0ba66 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 22:41:28 +0000 Subject: [PATCH 4/6] Express the fallback window as a range of small hours smallHoursEndHour read awkwardly: "hours" meant the wee hours while "Hour" meant an hour of the day. A range says it once, matches sleepBankDaysRange, and reads as a membership test at the call site. Co-authored-by: Greg --- Bedtime/Bedtime/Constants.swift | 4 ++-- Bedtime/Bedtime/Utils/SleepDay.swift | 12 ++++++------ Bedtime/BedtimeTests/SleepDayTests.swift | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Bedtime/Bedtime/Constants.swift b/Bedtime/Bedtime/Constants.swift index b4c74be..666dd9c 100644 --- a/Bedtime/Bedtime/Constants.swift +++ b/Bedtime/Bedtime/Constants.swift @@ -12,9 +12,9 @@ class Constants { static let iconWidth: CGFloat = 30 static let cardHeaderSpacing: CGFloat = 12 static let sleepHistoryDays = 30 - /// Before this hour, a night with no sleep recorded yet falls back to the night + /// The wee hours, when a night with no sleep recorded yet falls back to the night /// before: sleep may still be in progress, or the tracker may not have synced. - static let smallHoursEndHour = 4 + static let smallHours = 0..<4 /// Only suggest opening source apps that have written sleep data within this window. static let recentSourceAppLookbackDays = 7 /// Allowed lookback for sleep-bank / insight window selection (Settings slider, insights, list handle). diff --git a/Bedtime/Bedtime/Utils/SleepDay.swift b/Bedtime/Bedtime/Utils/SleepDay.swift index 1a550e4..37cc258 100644 --- a/Bedtime/Bedtime/Utils/SleepDay.swift +++ b/Bedtime/Bedtime/Utils/SleepDay.swift @@ -29,11 +29,11 @@ enum SleepDay { /// The sleep day a summary should feature, given the nights that have data. /// /// Prefers the day underway, so waking briefly at 2am shows tonight's sleep so far - /// rather than yesterday's total. In the small hours that day can have nothing - /// recorded yet — sleep is still in progress, or the tracker hasn't synced — so it - /// falls back to the night before. Later in the day an empty night stays empty, - /// leaving the no-data state free to prompt a sync instead of resurrecting older - /// sleep. + /// rather than yesterday's total. During `Constants.smallHours` that day can have + /// nothing recorded yet — sleep is still in progress, or the tracker hasn't synced — + /// so it falls back to the night before. Later in the day an empty night stays + /// empty, leaving the no-data state free to prompt a sync instead of resurrecting + /// older sleep. static func featured( in nights: [Date: Sessions], now: Date = Date(), @@ -43,7 +43,7 @@ enum SleepDay { guard nights[nightUnderway] == nil, - calendar.component(.hour, from: now) < Constants.smallHoursEndHour, + Constants.smallHours.contains(calendar.component(.hour, from: now)), let previousNight = previous(before: nightUnderway, calendar: calendar) else { return nightUnderway diff --git a/Bedtime/BedtimeTests/SleepDayTests.swift b/Bedtime/BedtimeTests/SleepDayTests.swift index 9261120..f7df091 100644 --- a/Bedtime/BedtimeTests/SleepDayTests.swift +++ b/Bedtime/BedtimeTests/SleepDayTests.swift @@ -164,21 +164,21 @@ struct FeaturedNightTests { #expect(featured == sleepDay(6, 12)) } - @Test("Falling back stops at the small hours cutoff") - func fallbackStopsAtCutoff() { + @Test("Falling back stops once the small hours are over") + func fallbackStopsAfterSmallHours() { let nights = [sleepDay(6, 10): recorded] let justBefore = SleepDay.featured( in: nights, - now: moment(6, 11, hour: Constants.smallHoursEndHour - 1, minute: 59), + now: moment(6, 11, hour: Constants.smallHours.upperBound - 1, minute: 59), calendar: testCalendar ) - let atCutoff = SleepDay.featured( + let afterSmallHours = SleepDay.featured( in: nights, - now: moment(6, 11, hour: Constants.smallHoursEndHour), + now: moment(6, 11, hour: Constants.smallHours.upperBound), calendar: testCalendar ) #expect(justBefore == sleepDay(6, 10)) - #expect(atCutoff == sleepDay(6, 11)) + #expect(afterSmallHours == sleepDay(6, 11)) } } From 81426d3b2c07e3fcbaa98f748870ebe3a62a0162 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 22:42:45 +0000 Subject: [PATCH 5/6] Read the fallback as a positive condition The guard's success path meant "don't fall back," so the branch that returned the previous night was the one that fell through it. An if states the case directly: nothing recorded yet and still the small hours means use the night before. Co-authored-by: Greg --- Bedtime/Bedtime/Utils/SleepDay.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Bedtime/Bedtime/Utils/SleepDay.swift b/Bedtime/Bedtime/Utils/SleepDay.swift index 37cc258..f723498 100644 --- a/Bedtime/Bedtime/Utils/SleepDay.swift +++ b/Bedtime/Bedtime/Utils/SleepDay.swift @@ -40,15 +40,15 @@ enum SleepDay { calendar: Calendar = .current ) -> Date { let nightUnderway = containing(now, calendar: calendar) + let nothingRecordedYet = nights[nightUnderway] == nil + let stillTheSmallHours = Constants.smallHours.contains(calendar.component(.hour, from: now)) - guard - nights[nightUnderway] == nil, - Constants.smallHours.contains(calendar.component(.hour, from: now)), - let previousNight = previous(before: nightUnderway, calendar: calendar) - else { - return nightUnderway + if nothingRecordedYet, + stillTheSmallHours, + let previousNight = previous(before: nightUnderway, calendar: calendar) { + return previousNight } - return previousNight + return nightUnderway } } From 075e1ad0654130fc0ba7bdb332c355d406b5f0be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 22:44:19 +0000 Subject: [PATCH 6/6] Call the fallback window the wee hours Co-authored-by: Greg --- Bedtime/Bedtime/Constants.swift | 2 +- Bedtime/Bedtime/Utils/SleepDay.swift | 6 +++--- Bedtime/BedtimeTests/SleepDayTests.swift | 20 ++++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Bedtime/Bedtime/Constants.swift b/Bedtime/Bedtime/Constants.swift index 666dd9c..62d469b 100644 --- a/Bedtime/Bedtime/Constants.swift +++ b/Bedtime/Bedtime/Constants.swift @@ -14,7 +14,7 @@ class Constants { static let sleepHistoryDays = 30 /// The wee hours, when a night with no sleep recorded yet falls back to the night /// before: sleep may still be in progress, or the tracker may not have synced. - static let smallHours = 0..<4 + static let weeHours = 0..<4 /// Only suggest opening source apps that have written sleep data within this window. static let recentSourceAppLookbackDays = 7 /// Allowed lookback for sleep-bank / insight window selection (Settings slider, insights, list handle). diff --git a/Bedtime/Bedtime/Utils/SleepDay.swift b/Bedtime/Bedtime/Utils/SleepDay.swift index f723498..8945bbf 100644 --- a/Bedtime/Bedtime/Utils/SleepDay.swift +++ b/Bedtime/Bedtime/Utils/SleepDay.swift @@ -29,7 +29,7 @@ enum SleepDay { /// The sleep day a summary should feature, given the nights that have data. /// /// Prefers the day underway, so waking briefly at 2am shows tonight's sleep so far - /// rather than yesterday's total. During `Constants.smallHours` that day can have + /// rather than yesterday's total. During `Constants.weeHours` that day can have /// nothing recorded yet — sleep is still in progress, or the tracker hasn't synced — /// so it falls back to the night before. Later in the day an empty night stays /// empty, leaving the no-data state free to prompt a sync instead of resurrecting @@ -41,10 +41,10 @@ enum SleepDay { ) -> Date { let nightUnderway = containing(now, calendar: calendar) let nothingRecordedYet = nights[nightUnderway] == nil - let stillTheSmallHours = Constants.smallHours.contains(calendar.component(.hour, from: now)) + let stillTheWeeHours = Constants.weeHours.contains(calendar.component(.hour, from: now)) if nothingRecordedYet, - stillTheSmallHours, + stillTheWeeHours, let previousNight = previous(before: nightUnderway, calendar: calendar) { return previousNight } diff --git a/Bedtime/BedtimeTests/SleepDayTests.swift b/Bedtime/BedtimeTests/SleepDayTests.swift index f7df091..9bba3ef 100644 --- a/Bedtime/BedtimeTests/SleepDayTests.swift +++ b/Bedtime/BedtimeTests/SleepDayTests.swift @@ -82,8 +82,8 @@ struct FeaturedNightTests { /// Stands in for a night's sessions; only the presence of a key matters here. private let recorded = ["session"] - @Test("Waking in the small hours features tonight's sleep so far") - func smallHoursPrefersNightUnderway() { + @Test("Waking in the wee hours features tonight's sleep so far") + func weeHoursPrefersNightUnderway() { let nights = [sleepDay(6, 10): recorded, sleepDay(6, 11): recorded] let featured = SleepDay.featured( @@ -95,8 +95,8 @@ struct FeaturedNightTests { #expect(featured == sleepDay(6, 11)) } - @Test("The small hours fall back when tonight has nothing recorded yet") - func smallHoursFallsBack() { + @Test("The wee hours fall back when tonight has nothing recorded yet") + func weeHoursFallsBack() { let nights = [sleepDay(6, 10): recorded] let featured = SleepDay.featured( @@ -164,21 +164,21 @@ struct FeaturedNightTests { #expect(featured == sleepDay(6, 12)) } - @Test("Falling back stops once the small hours are over") - func fallbackStopsAfterSmallHours() { + @Test("Falling back stops once the wee hours are over") + func fallbackStopsAfterWeeHours() { let nights = [sleepDay(6, 10): recorded] let justBefore = SleepDay.featured( in: nights, - now: moment(6, 11, hour: Constants.smallHours.upperBound - 1, minute: 59), + now: moment(6, 11, hour: Constants.weeHours.upperBound - 1, minute: 59), calendar: testCalendar ) - let afterSmallHours = SleepDay.featured( + let afterWeeHours = SleepDay.featured( in: nights, - now: moment(6, 11, hour: Constants.smallHours.upperBound), + now: moment(6, 11, hour: Constants.weeHours.upperBound), calendar: testCalendar ) #expect(justBefore == sleepDay(6, 10)) - #expect(afterSmallHours == sleepDay(6, 11)) + #expect(afterWeeHours == sleepDay(6, 11)) } }