-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Incorporate sleep data into complication user info transfer calculations #1217
Conversation
Does the Apple Watch or iOS passively track sleep times and record them to Health if you don’t use an active sleep tracker? A quick check of a non-health-tracking phone shows no sleep data, so I assume not. If not, does this PR default to the previous behavior, or to a default bedtime, or something else? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, Anna, for taking this on! Using HealthKit data to improve the update interval without a user setting is the right path forward, and this is a great start. I've included a few suggestions.
Loop/Managers/WatchDataManager.swift
Outdated
@@ -40,6 +44,37 @@ final class WatchDataManager: NSObject { | |||
}() | |||
|
|||
private var lastSentSettings: LoopSettings? | |||
|
|||
let healthStore = HKHealthStore() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than create a new store object, use the app's shared HKHealthStore, loopDataManager.glucoseStore.healthStore
(going through carbStore
or doseStore
would work just as well).
Loop/Managers/WatchDataManager.swift
Outdated
@@ -40,6 +44,37 @@ final class WatchDataManager: NSObject { | |||
}() | |||
|
|||
private var lastSentSettings: LoopSettings? | |||
|
|||
let healthStore = HKHealthStore() | |||
private var lastBedtimeUpdate: Date = Calendar.current.date(byAdding: .hour, value: -25, to: Date())! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can use .distantPast
as the initial value here.
Loop/Managers/WatchDataManager.swift
Outdated
let lastUpdateInterval = Date().timeIntervalSince(lastBedtimeUpdate) | ||
guard | ||
lastUpdateInterval < TimeInterval(hours: 24) | ||
else { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A style nit: if you invert the logic here, you can drop a level of indentation by moving the logic beneath the guard
; i.e.
guard lastUpdateInterval >= .hours(24) else {
return
}
// remainder of the function body
Loop/Managers/WatchDataManager.swift
Outdated
private var lastBedtimeUpdate: Date = Calendar.current.date(byAdding: .hour, value: -25, to: Date())! | ||
private var bedtime: Date? | ||
|
||
private func updateBedtime() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this function only performs work conditionally, consider naming it updateBedtimeIfNeeded
.
Loop/Managers/WatchDataManager.swift
Outdated
(result) in | ||
|
||
// update when we last checked the bedtime | ||
self.lastBedtimeUpdate = Date() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we only update the bedtime date in the successful case, should this variable only be updated on that path?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This variable should be renamed, as it's intended to be the last time that we've attempted to find the bedtime. I had it update even in a failure case to ensure that where there's no sleep data, the query doesn't get attempted every time the function is called. Would this be the desired behavior?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's reasonable—and it prompted me to look at the behavior of the bedtime computation function when no sleep data exists. Added an additional comment down there.
Loop/Managers/WatchDataManager.swift
Outdated
if let error = error { | ||
completion(.failure(error)) | ||
} else if let samples = samples as? [HKCategorySample] { | ||
// find the average hour and minute components from the sleep start times |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Each sample's value
property will correspond to a value of HKCategoryValueSleepAnalysis. If a user has a sleep tracking app, they might have primarily .asleep
values; if the data comes from Bedtime in the Clock app, they'll have primarily .inBed
values. It's possible that a user has a value of each type each night, in which case they'll both be averaged.
Consider performing an additional step here which filters samples down to those with .inBed
values to use in the computation. If there aren't "enough", try .asleep
instead. In any case, we'll want to make sure we don't include any .awake
values.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. For the purposes of "enough," what could be a decent threshold? The current code just needs one value for it to use the .inBed values instead of the .asleep values, but I'm not sure what the ideal amount would be
Loop/Managers/WatchDataManager.swift
Outdated
|
||
// find the next time that the user will go to bed, based on the averages we've computed | ||
if let time = Calendar.current.nextDate(after: Date(), matching: DateComponents(hour: averageHour, minute: averageMinute), matchingPolicy: .nextTime) { | ||
completion(.success(time)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a tricky case here where the time might not exist within the next 24 hours, like in the hour jump that occurs for DST. It's worth doing a check that time.timeIntervalSinceNow <= .hours(24)
before proceeding.
Loop/Managers/WatchDataManager.swift
Outdated
if let time = Calendar.current.nextDate(after: Date(), matching: DateComponents(hour: averageHour, minute: averageMinute), matchingPolicy: .nextTime) { | ||
completion(.success(time)) | ||
} else { | ||
completion(.failure(NSError())) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You might consider defining a SleepStoreError enum to use in SleepStoreResult, with a noMatchingBedtime
case.
Loop/Managers/WatchDataManager.swift
Outdated
completion(.failure(NSError())) | ||
} | ||
} else { | ||
assertionFailure("Unknown return configuration from query \(query)") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since assertion failures trigger only in debug modes, it's best practice to still call the completion handler on this path—not that I'd expect it to happen!
Loop/Managers/WatchDataManager.swift
Outdated
} | ||
|
||
extension Date { | ||
fileprivate func secondsFromMidnight() -> Int { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function's body does what you want: produce a scalar value that encodes the time of day to be averaged. However, this scalar isn't always the number of seconds from midnight; consider DST. Something like timeOfDayInSeconds
would communicate the intention more accurately.
If Bedtime, an iOS feature, is set up, then it'll track the sleep times; there are also other iOS and/or Watch apps that can track sleep. If there is no data, this PR would default to the previous behavior, which uses |
Loop/Managers/WatchDataManager.swift
Outdated
completion(.failure(error)) | ||
} else if let samples = samples as? [HKCategorySample] { | ||
// find the average hour and minute components from the sleep start times | ||
let average = samples.reduce(0, {$0 + $1.startDate.secondsFromMidnight()}) / samples.count |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We'll want a guard
to check that !samples.isEmpty
to avoid trapping here if no sleep data is available. SleepStoreError can include a noSleepDataAvailable
case to represent the failure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking good! I have just a couple final requests (one or two of which I missed in the first review, that's on me!), then I think this'll be good to go from my perspective.
Loop/Managers/SleepStore.swift
Outdated
|
||
enum SleepStoreResult<T> { | ||
case success(T) | ||
case failure(Error) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can use SleepStoreError as the type contained by the failure
case.
Loop/Managers/SleepStore.swift
Outdated
} | ||
|
||
extension SleepStoreError: LocalizedError { | ||
public var localizedDescription: String { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't look like we'll be surfacing these errors to the user, so you can remove the localized descriptions.
Loop/Managers/SleepStore.swift
Outdated
|
||
class SleepStore { | ||
var healthStore: HKHealthStore | ||
var sampleLimit: Int |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sampleLimit
strikes me more as a parameter to the query, rather than as a property of the SleepStore itself. Consider making this a parameter to getAverageSleepStartTime
with a default value.
Loop/Managers/SleepStore.swift
Outdated
|
||
extension Date { | ||
fileprivate func timeOfDayInSeconds() -> Int { | ||
let calendar = Calendar.current |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One detail here I forgot to mention—the time of day for a particular sleep sample is dependent upon the time zone in which the sample was recorded. For example, if a user went on vacation recently, using their home time zone to determine the time of day in seconds could yield a result that doesn't accurately reflect when they went to sleep in their vacation location's time zone.
Add a parameter to this function of type TimeZone, and assign it to the timeZone
property of calendar
. You can retrieve the TimeZone for each sleep sample by retrieving the name string from the sample's metadata via HKMetadataKeyTimeZone.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For unwrapping the timezone, what could an appropriate default be? We could use the current time zone, or exclude the sample from the analysis (though this would probably mean it wouldn't be possible to use a reduce function to average the samples)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just checked my sleep data, from SleepTracker, and it has timezone. Whether or not this field is present will depend on the app, and since it's metadata, the app isn't forced to store it. I think assuming local timezone in that case is probably the best option, as the user probably will have no samples with timezone.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good. Just checked my sleep data (some from Bedtime, some from SleepCycle) and it all contains timezones
Loop/Managers/WatchDataManager.swift
Outdated
@@ -40,6 +43,52 @@ final class WatchDataManager: NSObject { | |||
}() | |||
|
|||
private var lastSentSettings: LoopSettings? | |||
|
|||
let healthStore: HKHealthStore |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe we no longer need a HKHealthStore instance directly inside WatchDataManager, since the access is managed through SleepStore; is this property used anywhere?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, removed
Loop/Managers/WatchDataManager.swift
Outdated
let hourComponent = calendar.component(.hour, from: bedtime) | ||
let minuteComponent = calendar.component(.minute, from: bedtime) | ||
|
||
if let newBedtime = calendar.nextDate(after: now, matching: DateComponents(hour: hourComponent, minute: minuteComponent), matchingPolicy: .nextTime) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's another case where newBedtime
could be more than a day in the future in case of DST. We could add a newBedtime.timeIntervalSince(bedtime) <= .hours(24)
clause similarly to before; alternatively, see my comment in complicationUserInfoTransferInterval
below.
Loop/Managers/WatchDataManager.swift
Outdated
// we can have a more frequent refresh rate if we only refresh when it's likely the user is awake (based on HealthKit sleep data) | ||
if let nextBedtime = bedtime { | ||
let timeUntilBedtime = nextBedtime.timeIntervalSince(now) | ||
timeUntilRefresh = timeUntilBedtime > TimeInterval(0) ? timeUntilBedtime : midnight.timeIntervalSince(now) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We might defensively make the condition in this ternary also check an upper bound on timeUntilBedtime
, i.e. (0..<TimeInterval(hours: 24)).contains(timeUntilBedtime)
, to ensure that complication transfers aren't slowed down if bedtime
somehow ended up being a Date further in the future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great. Thanks Anna! And thanks Michael for reviewing.
Loop/Managers/SleepStore.swift
Outdated
|
||
extension Date { | ||
fileprivate func timeOfDayInSeconds() -> Int { | ||
let calendar = Calendar.current |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just checked my sleep data, from SleepTracker, and it has timezone. Whether or not this field is present will depend on the app, and since it's metadata, the app isn't forced to store it. I think assuming local timezone in that case is probably the best option, as the user probably will have no samples with timezone.
…a/Loop into novalegra-aq/update-complications
…date-complications
As discussed in #816 and #832, complication transfer intervals could be optimized if bedtime was incorporated into the calculations, as this would make the complication updates more likely to occur during waking hours.
This implementation uses HealthKit sleep data in order to determine "bedtime" without adding additional settings to Loop. This adds an extra HealthKit permission, for read access to sleep data. A HealthKit query for this data is made every 24 hours and uses a maximum of the most recent 30 samples from the past 6 months. The start times of these sleep samples are then averaged to arrive at the final "bedtime".
complicationUserInfoTransferInterval
was changed from a variable to a function to allow for bedtime to be incorporated. The time until complication allowance refresh is calculated withbedtime - now
if bedtime is in the future, ormidnight - now
if the bedtime is in the past. If there is no bedtime (bedtime is nil), the calculations are the same as in the current implementation.