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 = "