From 25a00374b12be9d166e98035678ac198148898a7 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:26:50 +0900 Subject: [PATCH 1/2] feat(health): parse WorkoutStatistics from Apple Health export Co-authored-by: Muse Code --- .../BetterFit/Models/WorkoutStatistics.swift | 205 ++++++++++++++++ .../HealthKit/HealthExportParser.swift | 208 ++++++++++++++++ .../WorkoutStatisticsTests.swift | 228 ++++++++++++++++++ 3 files changed, 641 insertions(+) create mode 100644 Sources/BetterFit/Models/WorkoutStatistics.swift create mode 100644 Sources/BetterFit/Services/HealthKit/HealthExportParser.swift create mode 100644 Tests/BetterFitTests/WorkoutStatisticsTests.swift diff --git a/Sources/BetterFit/Models/WorkoutStatistics.swift b/Sources/BetterFit/Models/WorkoutStatistics.swift new file mode 100644 index 0000000..3c3c30f --- /dev/null +++ b/Sources/BetterFit/Models/WorkoutStatistics.swift @@ -0,0 +1,205 @@ +import Foundation + +// MARK: - WorkoutStatistics + +/// A single child `WorkoutStatistics` element nested under a `Workout` +/// in an Apple Health export. +/// +/// HealthKit writes one of these per quantity type for the workout window, +/// for example active energy, distance, or heart rate. Fields that a given +/// type does not carry stay nil. A heart-rate statistic uses average, +/// minimum, and maximum. A distance or energy statistic uses sum. +public struct WorkoutStatistics: Codable, Equatable, Sendable { + public var type: String + public var startDate: Date? + public var endDate: Date? + public var sum: Double? + public var average: Double? + public var minimum: Double? + public var maximum: Double? + public var unit: String? + + public init( + type: String, + startDate: Date? = nil, + endDate: Date? = nil, + sum: Double? = nil, + average: Double? = nil, + minimum: Double? = nil, + maximum: Double? = nil, + unit: String? = nil + ) { + self.type = type + self.startDate = startDate + self.endDate = endDate + self.sum = sum + self.average = average + self.minimum = minimum + self.maximum = maximum + self.unit = unit + } + + // MARK: - Kind + + /// Coarse classification of the statistic type. + public enum Kind: Equatable, Sendable { + case activeEnergy + case basalEnergy + case distanceWalkingRunning + case distanceCycling + case heartRate + case stepCount + case other(String) + } + + public var kind: Kind { + switch type { + case "HKQuantityTypeIdentifierActiveEnergyBurned": + return .activeEnergy + case "HKQuantityTypeIdentifierBasalEnergyBurned": + return .basalEnergy + case "HKQuantityTypeIdentifierDistanceWalkingRunning": + return .distanceWalkingRunning + case "HKQuantityTypeIdentifierDistanceCycling": + return .distanceCycling + case "HKQuantityTypeIdentifierHeartRate": + return .heartRate + case "HKQuantityTypeIdentifierStepCount": + return .stepCount + default: + return .other(type) + } + } + + // MARK: - Normalised Values + + /// Sum expressed in kilocalories, or nil when this is not an energy + /// statistic or the unit cannot be normalised. + public var sumKilocalories: Double? { + guard kind == .activeEnergy || kind == .basalEnergy, let sum else { return nil } + switch unit?.lowercased() { + case "kcal", "cal", nil: + // Health export writes energy in kcal by default. Small "cal" + // values in the wild are also kilocalories in this context. + return sum + case "kj": + return sum / 4.184 + case "j": + return sum / 4184 + default: + return sum + } + } + + /// Sum expressed in metres, or nil when this is not a distance statistic. + public var sumMetres: Double? { + guard kind == .distanceWalkingRunning || kind == .distanceCycling, let sum else { + return nil + } + switch unit?.lowercased() { + case "km": + return sum * 1000 + case "mi": + return sum * 1609.344 + case "m", nil: + return sum + default: + return sum + } + } +} + +// MARK: - ParsedWorkout + +/// A workout parsed from an Apple Health export, with its child statistics. +public struct ParsedWorkout: Codable, Equatable, Sendable { + public var activityType: String + public var sourceName: String? + public var sourceVersion: String? + public var creationDate: Date? + public var startDate: Date? + public var endDate: Date? + /// Duration in seconds, normalised from the export duration and unit. + public var duration: TimeInterval? + /// Total energy burned in kilocalories as written on the workout element. + public var totalEnergyBurned: Double? + /// Total distance in metres as written on the workout element. + public var totalDistance: Double? + public var statistics: [WorkoutStatistics] + + public init( + activityType: String, + sourceName: String? = nil, + sourceVersion: String? = nil, + creationDate: Date? = nil, + startDate: Date? = nil, + endDate: Date? = nil, + duration: TimeInterval? = nil, + totalEnergyBurned: Double? = nil, + totalDistance: Double? = nil, + statistics: [WorkoutStatistics] = [] + ) { + self.activityType = activityType + self.sourceName = sourceName + self.sourceVersion = sourceVersion + self.creationDate = creationDate + self.startDate = startDate + self.endDate = endDate + self.duration = duration + self.totalEnergyBurned = totalEnergyBurned + self.totalDistance = totalDistance + self.statistics = statistics + } + + // MARK: - Statistic Lookup + + public func statistic(for kind: WorkoutStatistics.Kind) -> WorkoutStatistics? { + statistics.first { $0.kind == kind } + } + + // MARK: - Per-Workout Summaries + + /// Active energy in kilocalories. Prefers the child statistic, then + /// falls back to the total written on the workout element. + public var activeEnergyKilocalories: Double? { + statistic(for: .activeEnergy)?.sumKilocalories ?? totalEnergyBurned + } + + /// Basal energy in kilocalories, when present. + public var basalEnergyKilocalories: Double? { + statistic(for: .basalEnergy)?.sumKilocalories + } + + /// Distance in metres. Prefers the walking or running distance + /// statistic, then cycling, then the workout total. + public var distanceMetres: Double? { + if let value = statistic(for: .distanceWalkingRunning)?.sumMetres { return value } + if let value = statistic(for: .distanceCycling)?.sumMetres { return value } + return totalDistance + } + + /// Distance in kilometres, when a distance is available. + public var distanceKilometres: Double? { + distanceMetres.map { $0 / 1000 } + } + + /// Average heart rate in beats per minute, when present. + public var averageHeartRate: Double? { + statistic(for: .heartRate)?.average + } + + /// Minimum heart rate in beats per minute, when present. + public var minimumHeartRate: Double? { + statistic(for: .heartRate)?.minimum + } + + /// Maximum heart rate in beats per minute, when present. + public var maximumHeartRate: Double? { + statistic(for: .heartRate)?.maximum + } + + /// Step count summed over the workout, when present. + public var stepCount: Double? { + statistic(for: .stepCount)?.sum + } +} diff --git a/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift b/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift new file mode 100644 index 0000000..2cfc48d --- /dev/null +++ b/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift @@ -0,0 +1,208 @@ +import Foundation + +// MARK: - HealthExportParser + +/// Parses workouts and their child `WorkoutStatistics` 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. +/// +/// Example shape: +/// +/// +/// +/// +public enum HealthExportParser { + + public enum ParseError: Error, Equatable { + case malformedXML(String) + } + + // MARK: - Public API + + /// Parse workouts from export XML data. + public static func parseWorkouts(data: Data) throws -> [ParsedWorkout] { + let delegate = Delegate() + let parser = XMLParser(data: data) + parser.delegate = delegate + parser.shouldProcessNamespaces = false + + guard parser.parse() else { + let message = parser.parserError?.localizedDescription ?? "unknown XML error" + throw ParseError.malformedXML(message) + } + 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) + } + + // MARK: - Date Parsing + + static func parseHealthDate(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + return healthDateFormatter.date(from: value) + ?? healthDateFormatterNoOffset.date(from: value) + } + + private static let healthDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" + return formatter + }() + + private static let healthDateFormatterNoOffset: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() + + // MARK: - Value Normalisation + + static func normaliseDuration(value: Double?, unit: String?) -> TimeInterval? { + guard let value else { return nil } + switch unit?.lowercased() { + case "min": + return value * 60 + case "hr", "h": + return value * 3600 + case "s", "sec", nil: + return value + default: + return value + } + } + + static func normaliseDistanceToMetres(value: Double?, unit: String?) -> Double? { + guard 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 + } + } + + static func normaliseEnergyToKilocalories(value: Double?, unit: String?) -> Double? { + guard 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 + } + } +} + +// MARK: - XML Delegate + +private final class Delegate: NSObject, XMLParserDelegate { + private(set) var workouts: [ParsedWorkout] = [] + private(set) var parseError: HealthExportParser.ParseError? + private var current: ParsedWorkout? + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:] + ) { + switch elementName { + case "Workout": + current = Self.makeWorkout(attributes: attributeDict) + case "WorkoutStatistics": + guard current != nil else { return } + let stat = Self.makeStatistic(attributes: attributeDict) + current?.statistics.append(stat) + default: + break + } + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String? + ) { + if elementName == "Workout", let workout = current { + workouts.append(workout) + current = nil + } + } + + func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) { + self.parseError = .malformedXML(parseError.localizedDescription) + } + + // MARK: - Element Builders + + private static func makeWorkout(attributes: [String: String]) -> ParsedWorkout { + let duration = HealthExportParser.normaliseDuration( + value: attributes["duration"].flatMap(Double.init), + unit: attributes["durationUnit"] + ) + let totalDistance = HealthExportParser.normaliseDistanceToMetres( + value: attributes["totalDistance"].flatMap(Double.init), + unit: attributes["totalDistanceUnit"] + ) + let totalEnergy = HealthExportParser.normaliseEnergyToKilocalories( + value: attributes["totalEnergyBurned"].flatMap(Double.init), + unit: attributes["totalEnergyBurnedUnit"] + ) + return ParsedWorkout( + activityType: attributes["workoutActivityType"] ?? "", + sourceName: attributes["sourceName"], + sourceVersion: attributes["sourceVersion"], + creationDate: HealthExportParser.parseHealthDate(attributes["creationDate"]), + startDate: HealthExportParser.parseHealthDate(attributes["startDate"]), + endDate: HealthExportParser.parseHealthDate(attributes["endDate"]), + duration: duration, + totalEnergyBurned: totalEnergy, + totalDistance: totalDistance + ) + } + + private static func makeStatistic(attributes: [String: String]) -> WorkoutStatistics { + WorkoutStatistics( + type: attributes["type"] ?? "", + startDate: HealthExportParser.parseHealthDate(attributes["startDate"]), + endDate: HealthExportParser.parseHealthDate(attributes["endDate"]), + sum: attributes["sum"].flatMap(Double.init), + average: attributes["average"].flatMap(Double.init), + minimum: attributes["minimum"].flatMap(Double.init), + maximum: attributes["maximum"].flatMap(Double.init), + unit: attributes["unit"] + ) + } +} diff --git a/Tests/BetterFitTests/WorkoutStatisticsTests.swift b/Tests/BetterFitTests/WorkoutStatisticsTests.swift new file mode 100644 index 0000000..4eddf76 --- /dev/null +++ b/Tests/BetterFitTests/WorkoutStatisticsTests.swift @@ -0,0 +1,228 @@ +import XCTest + +@testable import BetterFit + +final class WorkoutStatisticsTests: XCTestCase { + + // MARK: - Fixtures + + private let sampleXML = [ + "", + "", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "" + ].joined(separator: "\n") + + // MARK: - Parsing + + func testParsesWorkoutsAndSkipsRecords() throws { + let workouts = try HealthExportParser.parseWorkouts(xml: sampleXML) + XCTAssertEqual(workouts.count, 2) + XCTAssertEqual(workouts[0].activityType, "HKWorkoutActivityTypeTraditionalStrengthTraining") + XCTAssertEqual(workouts[1].activityType, "HKWorkoutActivityTypeRunning") + } + + func testParsesWorkoutMetadataAndDuration() throws { + let workouts = try HealthExportParser.parseWorkouts(xml: sampleXML) + let strength = workouts[0] + XCTAssertEqual(strength.sourceName, "Apple Watch") + XCTAssertEqual(strength.sourceVersion, "10.0") + // 62.5 min normalised to seconds + XCTAssertEqual(strength.duration ?? 0, 3750, accuracy: 0.001) + XCTAssertNotNil(strength.startDate) + XCTAssertNotNil(strength.endDate) + XCTAssertNotNil(strength.creationDate) + } + + func testParsesChildStatisticsCountAndTypes() throws { + let workouts = try HealthExportParser.parseWorkouts(xml: sampleXML) + XCTAssertEqual(workouts[0].statistics.count, 3) + XCTAssertEqual(workouts[1].statistics.count, 4) + XCTAssertEqual(workouts[0].statistics.map(\.kind), [.activeEnergy, .basalEnergy, .heartRate]) + } + + // MARK: - Per-Workout Summaries + + func testEnergySummariesFromStatistics() throws { + let workouts = try HealthExportParser.parseWorkouts(xml: sampleXML) + let strength = workouts[0] + XCTAssertEqual(strength.activeEnergyKilocalories ?? 0, 320.5, accuracy: 0.001) + XCTAssertEqual(strength.basalEnergyKilocalories ?? 0, 82.1, accuracy: 0.001) + } + + func testHeartRateSummary() throws { + let workouts = try HealthExportParser.parseWorkouts(xml: sampleXML) + let run = workouts[1] + XCTAssertEqual(run.averageHeartRate ?? 0, 155, accuracy: 0.001) + XCTAssertEqual(run.minimumHeartRate ?? 0, 105, accuracy: 0.001) + XCTAssertEqual(run.maximumHeartRate ?? 0, 178, accuracy: 0.001) + } + + func testDistanceSummaryPrefersStatisticOverTotal() throws { + let workouts = try HealthExportParser.parseWorkouts(xml: sampleXML) + let run = workouts[1] + // Statistic is 5.02 km, workout total is 5 km. Statistic wins. + XCTAssertEqual(run.distanceMetres ?? 0, 5020, accuracy: 0.001) + XCTAssertEqual(run.distanceKilometres ?? 0, 5.02, accuracy: 0.001) + } + + func testStepCountSummary() throws { + let workouts = try HealthExportParser.parseWorkouts(xml: sampleXML) + XCTAssertEqual(workouts[1].stepCount ?? 0, 6200, accuracy: 0.001) + XCTAssertNil(workouts[0].stepCount) + } + + func testFallsBackToWorkoutTotalsWhenStatisticsAbsent() throws { + let xml = """ + + + + """ + let workouts = try HealthExportParser.parseWorkouts(xml: xml) + XCTAssertEqual(workouts.count, 1) + XCTAssertEqual(workouts[0].activeEnergyKilocalories ?? 0, 500, accuracy: 0.001) + XCTAssertEqual(workouts[0].distanceMetres ?? 0, 20000, accuracy: 0.001) + XCTAssertNil(workouts[0].averageHeartRate) + } + + // MARK: - Edge Cases + + func testWorkoutWithoutStatisticsHasNilSummaries() throws { + let xml = """ + + + + """ + let workouts = try HealthExportParser.parseWorkouts(xml: xml) + XCTAssertEqual(workouts.count, 1) + XCTAssertTrue(workouts[0].statistics.isEmpty) + XCTAssertNil(workouts[0].activeEnergyKilocalories) + XCTAssertNil(workouts[0].distanceMetres) + XCTAssertNil(workouts[0].averageHeartRate) + XCTAssertEqual(workouts[0].duration ?? 0, 2700, accuracy: 0.001) + } + + func testPreservesUnknownStatisticType() throws { + let xml = """ + + + + + + """ + let workouts = try HealthExportParser.parseWorkouts(xml: xml) + XCTAssertEqual(workouts[0].statistics.count, 1) + XCTAssertEqual(workouts[0].statistics[0].kind, .other("HKQuantityTypeIdentifierVO2Max")) + XCTAssertEqual(workouts[0].statistics[0].sum ?? 0, 42, accuracy: 0.001) + } + + func testEmptyExportReturnsNoWorkouts() throws { + let xml = "" + XCTAssertEqual(try HealthExportParser.parseWorkouts(xml: xml).count, 0) + } + + func testMalformedXMLThrows() { + let broken = " Date: Sat, 7 Nov 2026 22:54:07 +0900 Subject: [PATCH 2/2] fix(health): import FoundationXML for Linux parsing --- Sources/BetterFit/Services/HealthKit/HealthExportParser.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift b/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift index 2cfc48d..5605bce 100644 --- a/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift +++ b/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationXML) +import FoundationXML +#endif // MARK: - HealthExportParser