Skip to content
Open
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
25 changes: 23 additions & 2 deletions RCTAppleHealthKit/Swift/BucketedQueries/BucketedSleep.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -19,6 +26,7 @@ struct SleepValue {
var outOfBed: Date?
var fellAsleep: Date?
var wokeUp: Date?
var phases: [SleepPhase]?
}

@available(iOS 10.0, *)
Expand All @@ -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 {
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -187,21 +187,19 @@ 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

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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
52 changes: 44 additions & 8 deletions RCTAppleHealthKit/Swift/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}
Expand Down
8 changes: 8 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -517,6 +520,11 @@ declare module 'react-native-health' {
sleepTimes?: {
fellAsleepAt?: string
wokeUpAt?: string
phases?: {
phase: string
startDate: string
endDate: string
}[]
}
}
}
Expand Down