Skip to content
Draft
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
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@ 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)

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
Expand Down
126 changes: 126 additions & 0 deletions Bedtime/Bedtime.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -16,6 +27,11 @@
path = Bedtime;
sourceTree = "<group>";
};
37B0000022E91B7E900D8ED8 /* BedtimeTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = BedtimeTests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */

/* Begin PBXFrameworksBuildPhase section */
Expand All @@ -26,13 +42,21 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
37B0000042E91B7E900D8ED8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
373AAED42E91B7E900D8ED84 = {
isa = PBXGroup;
children = (
373AAEDF2E91B7E900D8ED84 /* Bedtime */,
37B0000022E91B7E900D8ED8 /* BedtimeTests */,
373AAEDE2E91B7E900D8ED84 /* Products */,
);
sourceTree = "<group>";
Expand All @@ -41,6 +65,7 @@
isa = PBXGroup;
children = (
373AAEDD2E91B7E900D8ED84 /* Bedger.app */,
37B0000012E91B7E900D8ED8 /* BedtimeTests.xctest */,
);
name = Products;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */
Expand All @@ -83,6 +131,10 @@
373AAEDC2E91B7E900D8ED84 = {
CreatedOnToolsVersion = 26.0;
};
37B0000062E91B7E900D8ED8 = {
CreatedOnToolsVersion = 26.0;
TestTargetID = 373AAEDC2E91B7E900D8ED84;
};
};
};
buildConfigurationList = 373AAED82E91B7E900D8ED84 /* Build configuration list for PBXProject "Bedtime" */;
Expand All @@ -100,6 +152,7 @@
projectRoot = "";
targets = (
373AAEDC2E91B7E900D8ED84 /* Bedtime */,
37B0000062E91B7E900D8ED8 /* BedtimeTests */,
);
};
/* End PBXProject section */
Expand All @@ -112,6 +165,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
37B0000052E91B7E900D8ED8 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */

/* Begin PBXSourcesBuildPhase section */
Expand All @@ -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;
Expand Down Expand Up @@ -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 */
Expand All @@ -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 */;
Expand Down
3 changes: 3 additions & 0 deletions Bedtime/Bedtime/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
17 changes: 7 additions & 10 deletions Bedtime/Bedtime/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -153,7 +150,7 @@ struct ContentView: View {
wakeTime: wakeTimeBinding
)
} else {
LastNightCard(sleepSessions: lastNightData,
LastNightCard(sleepSessions: featuredNightData,
goal: userPreferences.sleepGoalHours,
sourceAppLinks: recentSourceAppLinks)
}
Expand Down
4 changes: 1 addition & 3 deletions Bedtime/Bedtime/Models/SleepData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down
54 changes: 54 additions & 0 deletions Bedtime/Bedtime/Utils/SleepDay.swift
Original file line number Diff line number Diff line change
@@ -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<Sessions>(
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
}
}
Loading