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/Bedtime/Constants.swift b/Bedtime/Bedtime/Constants.swift index 654cd89..62d469b 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 + /// 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 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/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 81cfafb..7b6ff49 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -25,19 +25,16 @@ struct ContentView: View { _healthKitManager = StateObject(wrappedValue: HealthKitManager(sourcePreferences: sourcePrefs)) } - private var lastNightKey: Date { - let calendar = Calendar.current - return calendar.startOfDay( - for: calendar.date(byAdding: .hour, value: -4, to: Date()) ?? Date() - ) + private var featuredNight: Date { + SleepDay.featured(in: healthKitManager.sleepSessions) } - 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 +116,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 +150,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..8945bbf --- /dev/null +++ b/Bedtime/Bedtime/Utils/SleepDay.swift @@ -0,0 +1,54 @@ +// +// 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) + } + + /// 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.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 + /// older sleep. + static func featured( + in nights: [Date: Sessions], + now: Date = Date(), + calendar: Calendar = .current + ) -> Date { + let nightUnderway = containing(now, calendar: calendar) + let nothingRecordedYet = nights[nightUnderway] == nil + let stillTheWeeHours = Constants.weeHours.contains(calendar.component(.hour, from: now)) + + if nothingRecordedYet, + stillTheWeeHours, + let previousNight = previous(before: nightUnderway, calendar: calendar) { + return previousNight + } + + return nightUnderway + } +} diff --git a/Bedtime/BedtimeTests/SleepDayTests.swift b/Bedtime/BedtimeTests/SleepDayTests.swift new file mode 100644 index 0000000..9bba3ef --- /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 wee hours features tonight's sleep so far") + func weeHoursPrefersNightUnderway() { + 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 wee hours fall back when tonight has nothing recorded yet") + func weeHoursFallsBack() { + 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 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.weeHours.upperBound - 1, minute: 59), + calendar: testCalendar + ) + let afterWeeHours = SleepDay.featured( + in: nights, + now: moment(6, 11, hour: Constants.weeHours.upperBound), + calendar: testCalendar + ) + + #expect(justBefore == sleepDay(6, 10)) + #expect(afterWeeHours == sleepDay(6, 11)) + } +}