diff --git a/Sources/BetterFit/Models/HealthRecord.swift b/Sources/BetterFit/Models/HealthRecord.swift new file mode 100644 index 0000000..b0092da --- /dev/null +++ b/Sources/BetterFit/Models/HealthRecord.swift @@ -0,0 +1,162 @@ +import Foundation + +// MARK: - HealthRecord + +/// A single top-level `Record` element from an Apple Health export. +/// +/// Workouts carry their own child statistics. Everything else HealthKit +/// tracks across the day, such as steps, heart rate samples, body mass, +/// and sleep, arrives as a flat `Record`. Quantity records carry a numeric +/// `value`. Category records, such as sleep analysis, carry a string value +/// instead, which is kept in `categoryValue`. +public struct HealthRecord: Codable, Equatable, Sendable { + public var type: String + public var sourceName: String? + public var sourceVersion: String? + public var unit: String? + public var creationDate: Date? + public var startDate: Date? + public var endDate: Date? + /// Numeric value for quantity records. Nil for category records. + public var value: Double? + /// Raw string value for category records. Nil for quantity records. + public var categoryValue: String? + + public init( + type: String, + sourceName: String? = nil, + sourceVersion: String? = nil, + unit: String? = nil, + creationDate: Date? = nil, + startDate: Date? = nil, + endDate: Date? = nil, + value: Double? = nil, + categoryValue: String? = nil + ) { + self.type = type + self.sourceName = sourceName + self.sourceVersion = sourceVersion + self.unit = unit + self.creationDate = creationDate + self.startDate = startDate + self.endDate = endDate + self.value = value + self.categoryValue = categoryValue + } + + // MARK: - Kind + + /// Coarse classification of the record type. + public enum Kind: Equatable, Sendable { + case stepCount + case heartRate + case restingHeartRate + case activeEnergy + case basalEnergy + case distanceWalkingRunning + case distanceCycling + case bodyMass + case sleepAnalysis + case other(String) + } + + public var kind: Kind { + switch type { + case "HKQuantityTypeIdentifierStepCount": + return .stepCount + case "HKQuantityTypeIdentifierHeartRate": + return .heartRate + case "HKQuantityTypeIdentifierRestingHeartRate": + return .restingHeartRate + case "HKQuantityTypeIdentifierActiveEnergyBurned": + return .activeEnergy + case "HKQuantityTypeIdentifierBasalEnergyBurned": + return .basalEnergy + case "HKQuantityTypeIdentifierDistanceWalkingRunning": + return .distanceWalkingRunning + case "HKQuantityTypeIdentifierDistanceCycling": + return .distanceCycling + case "HKQuantityTypeIdentifierBodyMass": + return .bodyMass + case "HKCategoryTypeIdentifierSleepAnalysis": + return .sleepAnalysis + default: + return .other(type) + } + } + + // MARK: - Normalised Values + + /// Value expressed in kilocalories, or nil when this is not an energy record. + public var valueKilocalories: Double? { + guard kind == .activeEnergy || kind == .basalEnergy, let value else { return nil } + switch unit?.lowercased() { + case "kj": + return value / 4.184 + case "j": + return value / 4184 + case "kcal", "cal", nil: + return value + default: + return value + } + } + + /// Value expressed in metres, or nil when this is not a distance record. + public var valueMetres: Double? { + guard kind == .distanceWalkingRunning || kind == .distanceCycling, let value else { + return nil + } + switch unit?.lowercased() { + case "km": + return value * 1000 + case "mi": + return value * 1609.344 + case "m", nil: + return value + default: + return value + } + } + + /// Value expressed in kilograms, or nil when this is not a body mass record. + public var valueKilograms: Double? { + guard kind == .bodyMass, let value else { return nil } + switch unit?.lowercased() { + case "lb": + return value * 0.45359237 + case "g": + return value / 1000 + case "kg", nil: + return value + default: + return value + } + } + + /// Time between the start and end dates, when both are present. + /// Sleep records use this for time in each sleep stage. + public var duration: TimeInterval? { + guard let startDate, let endDate else { return nil } + return endDate.timeIntervalSince(startDate) + } +} + +// MARK: - HealthExport + +/// A parsed Apple Health export, with workouts and top-level records. +public struct HealthExport: Codable, Equatable, Sendable { + public var workouts: [ParsedWorkout] + public var records: [HealthRecord] + + public init(workouts: [ParsedWorkout] = [], records: [HealthRecord] = []) { + self.workouts = workouts + self.records = records + } + + // MARK: - Record Lookup + + public func records(of kind: HealthRecord.Kind) -> [HealthRecord] { + records.filter { $0.kind == kind } + } +} diff --git a/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift b/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift index 5605bce..40abb32 100644 --- a/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift +++ b/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift @@ -5,13 +5,14 @@ import FoundationXML // MARK: - HealthExportParser -/// Parses workouts and their child `WorkoutStatistics` from an Apple Health -/// export (`export.xml`). +/// Parses workouts and top-level records from an Apple Health export +/// (`export.xml`). /// /// The parser is Foundation only and runs on any platform, so export files /// can be processed off device and in unit tests. It reads `Workout` -/// elements and the `WorkoutStatistics` nested inside them. Top-level -/// `Record` elements are out of scope for this slice and are skipped. +/// elements and the `WorkoutStatistics` nested inside them, plus the +/// top-level `Record` elements for steps, heart rate, energy, distance, +/// body mass, and sleep. /// /// Example shape: /// @@ -35,6 +36,38 @@ public enum HealthExportParser { /// Parse workouts from export XML data. public static func parseWorkouts(data: Data) throws -> [ParsedWorkout] { + try parse(data: data).workouts + } + + /// Parse workouts from an export XML string. + public static func parseWorkouts(xml: String) throws -> [ParsedWorkout] { + try parseExport(xml: xml).workouts + } + + /// Parse top-level records from export XML data. + public static func parseRecords(data: Data) throws -> [HealthRecord] { + try parse(data: data).records + } + + /// Parse top-level records from an export XML string. + public static func parseRecords(xml: String) throws -> [HealthRecord] { + try parseExport(xml: xml).records + } + + /// Parse a full export, with workouts and top-level records, from XML data. + public static func parseExport(data: Data) throws -> HealthExport { + try parse(data: data) + } + + /// Parse a full export, with workouts and top-level records, from an XML string. + public static func parseExport(xml: String) throws -> HealthExport { + guard let data = xml.data(using: .utf8) else { + throw ParseError.malformedXML("input is not valid UTF-8") + } + return try parse(data: data) + } + + private static func parse(data: Data) throws -> HealthExport { let delegate = Delegate() let parser = XMLParser(data: data) parser.delegate = delegate @@ -47,15 +80,7 @@ public enum HealthExportParser { if let error = delegate.parseError { throw error } - return delegate.workouts - } - - /// Parse workouts from an export XML string. - public static func parseWorkouts(xml: String) throws -> [ParsedWorkout] { - guard let data = xml.data(using: .utf8) else { - throw ParseError.malformedXML("input is not valid UTF-8") - } - return try parseWorkouts(data: data) + return HealthExport(workouts: delegate.workouts, records: delegate.records) } // MARK: - Date Parsing @@ -130,6 +155,7 @@ public enum HealthExportParser { private final class Delegate: NSObject, XMLParserDelegate { private(set) var workouts: [ParsedWorkout] = [] + private(set) var records: [HealthRecord] = [] private(set) var parseError: HealthExportParser.ParseError? private var current: ParsedWorkout? @@ -147,6 +173,8 @@ private final class Delegate: NSObject, XMLParserDelegate { guard current != nil else { return } let stat = Self.makeStatistic(attributes: attributeDict) current?.statistics.append(stat) + case "Record": + records.append(Self.makeRecord(attributes: attributeDict)) default: break } @@ -196,6 +224,22 @@ private final class Delegate: NSObject, XMLParserDelegate { ) } + private static func makeRecord(attributes: [String: String]) -> HealthRecord { + let rawValue = attributes["value"] + let numericValue = rawValue.flatMap(Double.init) + return HealthRecord( + type: attributes["type"] ?? "", + sourceName: attributes["sourceName"], + sourceVersion: attributes["sourceVersion"], + unit: attributes["unit"], + creationDate: HealthExportParser.parseHealthDate(attributes["creationDate"]), + startDate: HealthExportParser.parseHealthDate(attributes["startDate"]), + endDate: HealthExportParser.parseHealthDate(attributes["endDate"]), + value: numericValue, + categoryValue: numericValue == nil ? rawValue : nil + ) + } + private static func makeStatistic(attributes: [String: String]) -> WorkoutStatistics { WorkoutStatistics( type: attributes["type"] ?? "", diff --git a/Tests/BetterFitTests/HealthRecordTests.swift b/Tests/BetterFitTests/HealthRecordTests.swift new file mode 100644 index 0000000..1dad3f0 --- /dev/null +++ b/Tests/BetterFitTests/HealthRecordTests.swift @@ -0,0 +1,166 @@ +import XCTest + +@testable import BetterFit + +final class HealthRecordTests: XCTestCase { + + // MARK: - Fixtures + + private let sampleXML = [ + "", + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "" + ].joined(separator: "\n") + + // MARK: - Parsing + + func testParsesTopLevelRecords() throws { + let records = try HealthExportParser.parseRecords(xml: sampleXML) + XCTAssertEqual(records.count, 6) + XCTAssertEqual(records[0].kind, .stepCount) + XCTAssertEqual(records[0].value ?? 0, 8421, accuracy: 0.001) + XCTAssertEqual(records[0].sourceName, "iPhone") + XCTAssertEqual(records[0].sourceVersion, "17.0") + XCTAssertEqual(records[0].unit, "count") + XCTAssertNotNil(records[0].startDate) + XCTAssertNotNil(records[0].creationDate) + } + + func testQuantityRecordHasNoCategoryValue() throws { + let records = try HealthExportParser.parseRecords(xml: sampleXML) + XCTAssertNil(records[0].categoryValue) + } + + func testSleepRecordKeepsCategoryValueAndDuration() throws { + let records = try HealthExportParser.parseRecords(xml: sampleXML) + let sleep = try XCTUnwrap(records.first { $0.kind == .sleepAnalysis }) + XCTAssertNil(sleep.value) + XCTAssertEqual(sleep.categoryValue, "HKCategoryValueSleepAnalysisAsleepCore") + // 23:00 to 06:30 is 7.5 hours + XCTAssertEqual(sleep.duration ?? 0, 27000, accuracy: 0.001) + } + + func testParseExportReturnsWorkoutsAndRecordsTogether() throws { + let export = try HealthExportParser.parseExport(xml: sampleXML) + XCTAssertEqual(export.workouts.count, 1) + XCTAssertEqual(export.workouts[0].activityType, "HKWorkoutActivityTypeRunning") + XCTAssertEqual(export.records.count, 6) + XCTAssertEqual(export.records(of: .heartRate).count, 1) + XCTAssertEqual(export.records(of: .stepCount).first?.value ?? 0, 8421, accuracy: 0.001) + } + + func testParseWorkoutsStillSkipsRecordsAsWorkouts() throws { + let workouts = try HealthExportParser.parseWorkouts(xml: sampleXML) + XCTAssertEqual(workouts.count, 1) + } + + // MARK: - Normalised Values + + func testEnergyNormalisationFromKilojoules() throws { + let records = try HealthExportParser.parseRecords(xml: sampleXML) + let energy = try XCTUnwrap(records.first { $0.kind == .activeEnergy }) + // 2092 kJ is 500 kcal + XCTAssertEqual(energy.valueKilocalories ?? 0, 500, accuracy: 0.001) + } + + func testDistanceNormalisationFromMiles() throws { + let records = try HealthExportParser.parseRecords(xml: sampleXML) + let distance = try XCTUnwrap(records.first { $0.kind == .distanceWalkingRunning }) + XCTAssertEqual(distance.valueMetres ?? 0, 1609.344, accuracy: 0.001) + } + + func testBodyMassInKilogramsAndPounds() throws { + let records = try HealthExportParser.parseRecords(xml: sampleXML) + let mass = try XCTUnwrap(records.first { $0.kind == .bodyMass }) + XCTAssertEqual(mass.valueKilograms ?? 0, 81.5, accuracy: 0.001) + + let pounds = HealthRecord( + type: "HKQuantityTypeIdentifierBodyMass", unit: "lb", value: 100) + XCTAssertEqual(pounds.valueKilograms ?? 0, 45.359237, accuracy: 0.001) + } + + func testNormalisedValuesNilForWrongKind() { + let steps = HealthRecord( + type: "HKQuantityTypeIdentifierStepCount", unit: "count", value: 100) + XCTAssertNil(steps.valueKilocalories) + XCTAssertNil(steps.valueMetres) + XCTAssertNil(steps.valueKilograms) + } + + // MARK: - Edge Cases + + func testPreservesUnknownRecordType() throws { + let xml = """ + + + + """ + let records = try HealthExportParser.parseRecords(xml: xml) + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0].kind, .other("HKQuantityTypeIdentifierVO2Max")) + XCTAssertEqual(records[0].value ?? 0, 42, accuracy: 0.001) + } + + func testEmptyExportReturnsNoRecords() throws { + let xml = "" + XCTAssertEqual(try HealthExportParser.parseRecords(xml: xml).count, 0) + let export = try HealthExportParser.parseExport(xml: xml) + XCTAssertTrue(export.workouts.isEmpty) + XCTAssertTrue(export.records.isEmpty) + } + + func testMalformedXMLThrowsForRecords() { + let broken = "