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..5605bce
--- /dev/null
+++ b/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift
@@ -0,0 +1,211 @@
+import Foundation
+#if canImport(FoundationXML)
+import FoundationXML
+#endif
+
+// 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 = "