diff --git a/RCTAppleHealthKit/Swift/BucketedQueries/BucketedSleep.swift b/RCTAppleHealthKit/Swift/BucketedQueries/BucketedSleep.swift index c0139c9c..11f8ade0 100644 --- a/RCTAppleHealthKit/Swift/BucketedQueries/BucketedSleep.swift +++ b/RCTAppleHealthKit/Swift/BucketedQueries/BucketedSleep.swift @@ -11,6 +11,13 @@ struct SimpleSleepSample { var startDate: Date var endDate: Date var type: SleepType + var phase: String? +} + +struct SleepPhase { + var phase: String + var startDate: Date + var endDate: Date } struct SleepValue { @@ -19,6 +26,7 @@ struct SleepValue { var outOfBed: Date? var fellAsleep: Date? var wokeUp: Date? + var phases: [SleepPhase]? } @available(iOS 10.0, *) @@ -31,6 +39,7 @@ class BucketedSleep { func calculateSleepValue(samplesForDate: [SleepType: [SimpleSleepSample]]) -> SleepValue? { var record = SleepValue(duration: 0, inBed: nil, outOfBed: nil, fellAsleep: nil, wokeUp: nil) + var phases: [SleepPhase] = [] for (value, samples) in samplesForDate { for sample in samples { @@ -39,17 +48,29 @@ class BucketedSleep { // Update inBed and outBed times with earliest and latest record.inBed = record.inBed.map { min($0, sample.startDate) } ?? sample.startDate record.outOfBed = record.outOfBed.map { max($0, sample.endDate) } ?? sample.endDate - + case .asleep, .asleepUnspecified: // Update fellAsleep and wokeUp times with earliest and latest, increase duration record.fellAsleep = record.fellAsleep.map { min($0, sample.startDate) } ?? sample.startDate record.wokeUp = record.wokeUp.map { max($0, sample.endDate) } ?? sample.endDate record.duration += sample.endDate.timeIntervalSince(sample.startDate) + if let phase = sample.phase { + phases.append(SleepPhase(phase: phase, startDate: sample.startDate, endDate: sample.endDate)) + } + case .awake: + if let phase = sample.phase { + phases.append(SleepPhase(phase: phase, startDate: sample.startDate, endDate: sample.endDate)) + } default: break } } } - + + if !phases.isEmpty { + // Dict iteration order isn't guaranteed, so sort to keep phases chronological. + record.phases = phases.sorted { $0.startDate < $1.startDate } + } + return record } } diff --git a/RCTAppleHealthKit/Swift/BucketedQueries/RCTAppleHealthKit+BucketedQueries.swift b/RCTAppleHealthKit/Swift/BucketedQueries/RCTAppleHealthKit+BucketedQueries.swift index 8ecb20e3..f61fb228 100644 --- a/RCTAppleHealthKit/Swift/BucketedQueries/RCTAppleHealthKit+BucketedQueries.swift +++ b/RCTAppleHealthKit/Swift/BucketedQueries/RCTAppleHealthKit+BucketedQueries.swift @@ -187,13 +187,11 @@ import HealthKit // pick a single asleep source per night, which avoids inflating durations when // two sources (e.g. AutoSleep + Apple Watch, or paired Apple Watches) overlap. var sampleDict: [String: [String: [SleepType: [SimpleSleepSample]]]] = [:] - let cutOffHour = Calendar.current.component(.hour, from: start) + let cutOffHour = (options["sleepCutOffHour"] as? Int) ?? 12 for sleepSample in sleepSamples { let sleepSampleType = findSleepType(value: sleepSample.value) - if sleepSampleType == .awake { - continue - } + let phase = findSleepPhase(value: sleepSample.value) let dateKey = formatSleepDateKey(date: sleepSample.startDate, cutOff: cutOffHour) let sourceId = sleepSample.sourceRevision.source.bundleIdentifier @@ -201,7 +199,7 @@ import HealthKit var sourceDict = sampleDict[dateKey, default: [:]] var sleepTypeDict = sourceDict[sourceId, default: [:]] var samplesForType = sleepTypeDict[sleepSampleType, default: []] - var newSample = SimpleSleepSample(startDate: sleepSample.startDate, endDate: sleepSample.endDate, type: sleepSampleType) + var newSample = SimpleSleepSample(startDate: sleepSample.startDate, endDate: sleepSample.endDate, type: sleepSampleType, phase: phase) if let lastSample = samplesForType.last, lastSample.endDate > sleepSample.startDate { if lastSample.endDate >= sleepSample.endDate { @@ -229,23 +227,36 @@ import HealthKit // within a single query. var asleepSamples: [SimpleSleepSample]? var asleepKey: SleepType = .asleep - for (_, byType) in sourceDict { + var asleepSourceId: String? + for (sourceId, byType) in sourceDict { if let s = byType[.asleep] { - asleepSamples = s + // Include unspecified samples from the same source so naps recorded + // outside the user's Sleep Schedule (written as legacy ASLEEP) aren't lost. + asleepSamples = s + (byType[.asleepUnspecified] ?? []) asleepKey = .asleep + asleepSourceId = sourceId break } } if asleepSamples == nil { - for (_, byType) in sourceDict { + for (sourceId, byType) in sourceDict { if let s = byType[.asleepUnspecified] { asleepSamples = s asleepKey = .asleepUnspecified + asleepSourceId = sourceId break } } } + // Awake samples are only meaningful as phase data — pull them from the same + // source as the chosen asleep samples, and only when that source emits phased + // data (.asleep). Pre-iOS 16 / AutoSleep entries have no phase to surface. + var awakeSamples: [SimpleSleepSample] = [] + if asleepKey == .asleep, let sid = asleepSourceId, let s = sourceDict[sid]?[.awake] { + awakeSamples = s + } + // inBed samples are merged across every source so AutoSleep's time-in-bed // window still surfaces when a phased source wins the asleep selection but // doesn't emit inBed itself. min/max-only accumulation in calculateSleepValue @@ -264,6 +275,9 @@ import HealthKit if !inBedSamples.isEmpty { samplesForDate[.inBed] = inBedSamples } + if !awakeSamples.isEmpty { + samplesForDate[.awake] = awakeSamples + } guard let sleepValue = bucketedSleep.calculateSleepValue(samplesForDate: samplesForDate) else { continue diff --git a/RCTAppleHealthKit/Swift/Helpers.swift b/RCTAppleHealthKit/Swift/Helpers.swift index 38cacdbe..b0b41ce4 100644 --- a/RCTAppleHealthKit/Swift/Helpers.swift +++ b/RCTAppleHealthKit/Swift/Helpers.swift @@ -16,6 +16,26 @@ func createPredicate(from: Date?, to: Date?) -> NSPredicate? { } } +@available(iOS 10.0, *) +func findSleepPhase(value: HKCategoryValueSleepAnalysis.RawValue) -> String? { + if value == HKCategoryValueSleepAnalysis.awake.rawValue { + return "awake" + } + if #available(iOS 16.0, *) { + switch value { + case HKCategoryValueSleepAnalysis.asleepCore.rawValue: + return "light" + case HKCategoryValueSleepAnalysis.asleepDeep.rawValue: + return "deep" + case HKCategoryValueSleepAnalysis.asleepREM.rawValue: + return "rem" + default: + return nil + } + } + return nil +} + @available(iOS 10.0, *) func findSleepType(value: HKCategoryValueSleepAnalysis.RawValue) -> SleepType { switch value { @@ -117,21 +137,28 @@ func formatSleepDateKey(date: Date, cutOff: Int) -> String { var finalDate = date let sampleHour = Calendar.current.component(.hour, from: date) - if sampleHour > cutOff { - // If past the cut off then we want to get the date key for the next day + if sampleHour >= cutOff { + // If at or past the cut off then we want to get the date key for the next day finalDate = Calendar.current.date(byAdding: .day, value: 1, to: date)! } return formatDateKey(date: finalDate) } -// Format to YYYY-MM-DD HH:mm:ss.SSS as local time -func formatLocalString(date: Date) -> String { +// Format to YYYY-MM-DD HH:mm:ss.SSS as local time. The formatter is cached because +// DateFormatter construction is expensive and we call this once per phase sample. +// The timeZone is captured at first use — a mid-session timezone change won't +// refresh until the app is restarted. +private let localDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" formatter.timeZone = .current formatter.locale = Locale(identifier: "en_US_POSIX") - return formatter.string(from: date) + return formatter +}() + +func formatLocalString(date: Date) -> String { + return localDateFormatter.string(from: date) } func formatDoubleAsString(value: Double) -> String { @@ -175,8 +202,8 @@ func formatSleepRecord(date: String, type: String, sleepValue: SleepValue) -> NS } var timesInBed: [String: String] = [:] - var sleepTimes: [String: String] = [:] - + var sleepTimes: [String: Any] = [:] + if let inBedAt = sleepValue.inBed { timesInBed["inBedAt"] = formatLocalString(date: inBedAt) } @@ -189,7 +216,16 @@ func formatSleepRecord(date: String, type: String, sleepValue: SleepValue) -> NS if let wokeUpAt = sleepValue.wokeUp { sleepTimes["wokeUpAt"] = formatLocalString(date: wokeUpAt) } - + if let phases = sleepValue.phases, !phases.isEmpty { + sleepTimes["phases"] = phases.map { phase in + [ + "phase": phase.phase, + "startDate": formatLocalString(date: phase.startDate), + "endDate": formatLocalString(date: phase.endDate), + ] + } + } + if !timesInBed.isEmpty { entry["timesInBed"] = timesInBed } diff --git a/index.d.ts b/index.d.ts index 4ad32caa..d0b57a57 100644 --- a/index.d.ts +++ b/index.d.ts @@ -492,6 +492,9 @@ declare module 'react-native-health' { endTime: string bucketPeriod: 'day' | 'month' | 'year' unit?: HealthUnit + // Hour of the day (0-23) used to bucket sleep samples into nights. Samples at or + // past this hour roll into the next day's bucket. Defaults to 12 (noon). + sleepCutOffHour?: number } export interface BucketedRecord { @@ -517,6 +520,11 @@ declare module 'react-native-health' { sleepTimes?: { fellAsleepAt?: string wokeUpAt?: string + phases?: { + phase: string + startDate: string + endDate: string + }[] } } }