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.