Skip to content
Merged
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
50 changes: 46 additions & 4 deletions Sources/BetterFit/Models/DailyHealthSummary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
}
}
152 changes: 152 additions & 0 deletions Tests/BetterFitTests/SleepSummaryTests.swift
Original file line number Diff line number Diff line change
@@ -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 = """
<HealthData>
<Record type="HKCategoryTypeIdentifierSleepAnalysis" value="\(category)"
startDate="2026-04-19 01:00:00 +0000" endDate="2026-04-19 02:00:00 +0000"/>
</HealthData>
"""
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 = """
<HealthData>
<Record type="HKCategoryTypeIdentifierSleepAnalysis" value="\(category)"
startDate="2026-04-19 01:00:00 +0000" endDate="2026-04-19 02:00:00 +0000"/>
</HealthData>
"""
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 = """
<HealthData locale="en_AU">
<Record type="HKCategoryTypeIdentifierSleepAnalysis" value="HKCategoryValueSleepAnalysisInBed"
startDate="2026-04-19 00:00:00 +0000" endDate="2026-04-19 09:00:00 +0000"/>
<Record type="HKCategoryTypeIdentifierSleepAnalysis" value="HKCategoryValueSleepAnalysisAwake"
startDate="2026-04-19 02:00:00 +0000" endDate="2026-04-19 03:00:00 +0000"/>
<Record type="HKCategoryTypeIdentifierSleepAnalysis" value="HKCategoryValueSleepAnalysisAsleepCore"
startDate="2026-04-19 01:00:00 +0000" endDate="2026-04-19 02:00:00 +0000"/>
<Record type="HKCategoryTypeIdentifierSleepAnalysis" value="HKCategoryValueSleepAnalysisAsleepDeep"
startDate="2026-04-19 03:00:00 +0000" endDate="2026-04-19 05:00:00 +0000"/>
<Record type="HKCategoryTypeIdentifierSleepAnalysis" value="HKCategoryValueSleepAnalysisAsleep"
sourceName="Second device"
startDate="2026-04-19 03:30:00 +0000" endDate="2026-04-19 04:30:00 +0000"/>
<Record type="HKCategoryTypeIdentifierSleepAnalysis" value="5"
startDate="2026-04-19 05:00:00 +0000" endDate="2026-04-19 06:00:00 +0000"/>
</HealthData>
"""
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
}
}
19 changes: 19 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading