diff --git a/Sources/BetterFit/Models/DailyHealthSummary.swift b/Sources/BetterFit/Models/DailyHealthSummary.swift index 83d0a6d..bfd64bd 100644 --- a/Sources/BetterFit/Models/DailyHealthSummary.swift +++ b/Sources/BetterFit/Models/DailyHealthSummary.swift @@ -21,7 +21,10 @@ public struct DailyHealthSummary: Codable, Equatable, Sendable { public var restingHeartRate: Double? /// Most recent body mass sample of the day, in kilograms. public var bodyMassKilograms: Double? - /// Total time covered by sleep analysis records starting that day. + /// Time asleep in recognised sleep-analysis records starting that day. + /// Overlapping intervals count once. Awake, in-bed, unknown categories, + /// and intervals without a positive duration are excluded. + /// Nil when no valid asleep interval is present. public var sleepDuration: TimeInterval? public init( @@ -83,7 +86,7 @@ private struct DayAccumulator { private var heartRateCount: Int = 0 private var resting: (date: Date, value: Double)? private var bodyMass: (date: Date, value: Double)? - private var sleep: TimeInterval? + private var sleepIntervals: [DateInterval] = [] mutating func add(_ record: HealthRecord) { addTotal(record) @@ -121,7 +124,9 @@ private struct DayAccumulator { case .bodyMass: bodyMass = latest(bodyMass, record: record, value: record.valueKilograms) case .sleepAnalysis: - if let duration = record.duration { sleep = (sleep ?? 0) + duration } + if record.isAsleep, let start = record.startDate, let end = record.endDate, end > start { + sleepIntervals.append(DateInterval(start: start, end: end)) + } default: break } @@ -147,7 +152,44 @@ private struct DayAccumulator { averageHeartRate: heartRateCount > 0 ? heartRateSum / Double(heartRateCount) : nil, restingHeartRate: resting?.value, bodyMassKilograms: bodyMass?.value, - sleepDuration: sleep + sleepDuration: sleepDuration ) } + + // MARK: - Sleep + + private var sleepDuration: TimeInterval? { + let intervals = sleepIntervals.sorted { $0.start < $1.start } + guard var current = intervals.first else { return nil } + var total: TimeInterval = 0 + for interval in intervals.dropFirst() { + if interval.start <= current.end { + current.end = max(current.end, interval.end) + } else { + total += current.duration + current = interval + } + } + return total + current.duration + } +} + +// MARK: - Sleep Categories + +private extension HealthRecord { + var isAsleep: Bool { + switch categoryValue { + case "HKCategoryValueSleepAnalysisAsleep", + "HKCategoryValueSleepAnalysisAsleepUnspecified", + "HKCategoryValueSleepAnalysisAsleepCore", + "HKCategoryValueSleepAnalysisAsleepDeep", + "HKCategoryValueSleepAnalysisAsleepREM": + return true + case nil: + // HealthKit raw values for unspecified, core, deep, and REM sleep. + return value == 1 || value == 3 || value == 4 || value == 5 + default: + return false + } + } } diff --git a/Tests/BetterFitTests/SleepSummaryTests.swift b/Tests/BetterFitTests/SleepSummaryTests.swift new file mode 100644 index 0000000..8b60f0f --- /dev/null +++ b/Tests/BetterFitTests/SleepSummaryTests.swift @@ -0,0 +1,152 @@ +import Foundation +import Testing + +import BetterFit + +struct SleepSummaryTests { + + // MARK: - Categories + + @Test(arguments: [ + "HKCategoryValueSleepAnalysisAsleep", + "HKCategoryValueSleepAnalysisAsleepUnspecified", + "HKCategoryValueSleepAnalysisAsleepCore", + "HKCategoryValueSleepAnalysisAsleepDeep", + "HKCategoryValueSleepAnalysisAsleepREM", + "1", "3", "4", "5" + ]) + func countsRecognisedAsleepCategoriesFromXML(_ category: String) throws { + let xml = """ + + + + """ + let summaries = try HealthExportParser.parseExport(xml: xml).dailySummaries(calendar: utcCalendar) + #expect(summaries.first?.sleepDuration == 3600) + } + + @Test(arguments: [ + "HKCategoryValueSleepAnalysisInBed", "HKCategoryValueSleepAnalysisAwake", + "HKCategoryValueSleepAnalysisAsleepFuture", "unknown", "", + "0", "2", "6", "-1", "3.5", "nan", "inf" + ]) + func excludesNonAsleepAndUnknownCategoriesFromXML(_ category: String) throws { + let xml = """ + + + + """ + let summaries = try HealthExportParser.parseExport(xml: xml).dailySummaries(calendar: utcCalendar) + let day = try #require(summaries.first) + #expect(day.sleepDuration == nil) + } + + // MARK: - Intervals + + @Test + func mergesUnsortedOverlappingNestedAndDuplicateIntervals() { + let records = [ + sleep(start: 4, end: 6), sleep(start: 1, end: 3), + sleep(start: 2, end: 5), sleep(start: 2, end: 3), + sleep(start: 1, end: 3) + ] + // The union is 01:00 to 06:00, regardless of order or source duplication. + #expect(sleepDuration(records) == 18_000) + #expect(sleepDuration(Array(records.reversed())) == 18_000) + } + + @Test + func preservesGapsBetweenSeparateSleepPeriods() { + #expect(sleepDuration([ + sleep(start: 1, end: 2), sleep(start: 2, end: 3), + sleep(start: 4, end: 5), sleep(start: 14, end: 14.5) + ]) == 3.5 * 3600) + } + + @Test + func ignoresInvalidAndIncompleteIntervals() { + let invalid = [ + sleep(start: 3, end: 2), sleep(start: 2, end: 2), + sleep(start: nil, end: 2), sleep(start: 1, end: nil), + sleep(start: 1, end: 2, category: nil) + ] + #expect(sleepDuration(invalid) == nil) + #expect(sleepDuration(invalid + [sleep(start: 4, end: 5)]) == 3600) + #expect(HealthExport(records: [sleep(start: nil, end: 2)]) + .dailySummaries(calendar: utcCalendar).isEmpty) + } + + @Test + func keepsStartDayAllocationInTheSuppliedCalendar() { + var calendar = utcCalendar + calendar.timeZone = TimeZone(secondsFromGMT: 10 * 3600)! + let records = [sleep(start: 13, end: 15), sleep(start: 15, end: 16)] + let summaries = HealthExport(records: records).dailySummaries(calendar: calendar) + // 13:00 UTC is 23:00 local. The whole first interval stays on its start day. + #expect(summaries.map(\.date) == [date(hour: -10), date(hour: 14)]) + #expect(summaries.map(\.sleepDuration) == [7200, 3600]) + } + + // MARK: - End to End + + @Test + func exportsFourHoursAsleepFromMixedOverlappingXMLRecords() throws { + let xml = """ + + + + + + + + + """ + let summaries = try HealthExportParser.parseExport(xml: xml).dailySummaries(calendar: utcCalendar) + #expect(summaries.count == 1) + #expect(summaries.first?.sleepDuration == 14400) + #expect(summaries.csvString().contains("2026-04-19T00:00:00Z,,,,,,,,14400\n")) + + let data = try summaries.jsonData() + let json = try #require(String(data: data, encoding: .utf8)) + #expect(json.contains("\"sleepDuration\" : 14400")) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + #expect(try decoder.decode([DailyHealthSummary].self, from: data) == summaries) + } + + // MARK: - Fixtures + + private var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private func date(hour: Double) -> Date { + Date(timeIntervalSince1970: hour * 3600) + } + + private func sleep( + start: Double?, end: Double?, category: String? = "HKCategoryValueSleepAnalysisAsleepCore" + ) -> HealthRecord { + HealthRecord( + type: "HKCategoryTypeIdentifierSleepAnalysis", + startDate: start.map { date(hour: $0) }, + endDate: end.map { date(hour: $0) }, + categoryValue: category + ) + } + + private func sleepDuration(_ records: [HealthRecord]) -> TimeInterval? { + HealthExport(records: records).dailySummaries(calendar: utcCalendar).first?.sleepDuration + } +} diff --git a/docs/api.md b/docs/api.md index 32515c0..4ddcbf8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -385,6 +385,25 @@ public class SmartNotificationManager ## Services +### Health export sleep summaries + +`HealthExportParser.parseExport(xml:)` reads Apple Health export XML without +requiring HealthKit. Call `dailySummaries(calendar:)` on the result to aggregate +records by their start date in the supplied calendar. + +`DailyHealthSummary.sleepDuration` reports seconds asleep for legacy asleep, +unspecified, core, deep and REM categories, including their numeric HealthKit +values. Overlapping asleep intervals count once, including duplicates from +different sources. Awake, in-bed, unknown categories and incomplete or +non-positive intervals are excluded. A day without valid asleep intervals has +`nil` sleep duration, which remains an empty field in CSV and is omitted in JSON. +Apple documents the sleep categories and overlapping samples in +[HKCategoryValueSleepAnalysis](https://developer.apple.com/documentation/healthkit/hkcategoryvaluesleepanalysis). + +Intervals crossing midnight remain entirely on their start day. The summary does +not prioritise sources or subtract awake samples that conflict with asleep +samples. CSV and JSON exports use the same corrected duration. + ### `AutoTrackingService` Auto-tracking service for Watch sensors.