diff --git a/Sources/BetterFit/Models/HealthActivitySummary.swift b/Sources/BetterFit/Models/HealthActivitySummary.swift
new file mode 100644
index 0000000..bef9425
--- /dev/null
+++ b/Sources/BetterFit/Models/HealthActivitySummary.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+/// A daily `ActivitySummary` element from an Apple Health export.
+///
+/// These are Apple's activity ring totals, kept separate from record-derived
+/// daily summaries so the same energy is not counted twice.
+public struct HealthActivitySummary: Codable, Equatable, Sendable {
+ /// Exported calendar day (`yyyy-MM-dd`), without a time zone or time of day.
+ public var dateComponents: String?
+ public var activeEnergyBurned: Double?
+ public var activeEnergyBurnedGoal: Double?
+ /// Exported unit shared by active energy and its goal.
+ public var activeEnergyBurnedUnit: String?
+ /// Exercise time in minutes, as exported by Apple Health.
+ public var appleExerciseTime: Double?
+ /// Exercise goal in minutes.
+ public var appleExerciseTimeGoal: Double?
+ public var appleStandHours: Double?
+ public var appleStandHoursGoal: Double?
+
+ public init(
+ dateComponents: String? = nil,
+ activeEnergyBurned: Double? = nil,
+ activeEnergyBurnedGoal: Double? = nil,
+ activeEnergyBurnedUnit: String? = nil,
+ appleExerciseTime: Double? = nil,
+ appleExerciseTimeGoal: Double? = nil,
+ appleStandHours: Double? = nil,
+ appleStandHoursGoal: Double? = nil
+ ) {
+ self.dateComponents = dateComponents
+ self.activeEnergyBurned = activeEnergyBurned
+ self.activeEnergyBurnedGoal = activeEnergyBurnedGoal
+ self.activeEnergyBurnedUnit = activeEnergyBurnedUnit
+ self.appleExerciseTime = appleExerciseTime
+ self.appleExerciseTimeGoal = appleExerciseTimeGoal
+ self.appleStandHours = appleStandHours
+ self.appleStandHoursGoal = appleStandHoursGoal
+ }
+
+ // MARK: - Normalised Energy
+
+ /// Energy in kilocalories. Nil for missing or unsupported units.
+ public var activeEnergyKilocalories: Double? {
+ kilocalories(activeEnergyBurned)
+ }
+
+ /// Energy goal in kilocalories. Nil for missing or unsupported units.
+ public var activeEnergyGoalKilocalories: Double? {
+ kilocalories(activeEnergyBurnedGoal)
+ }
+
+ private func kilocalories(_ value: Double?) -> Double? {
+ guard let value else { return nil }
+ switch activeEnergyBurnedUnit?.lowercased() {
+ case "kcal": return value
+ case "kj": return value / 4.184
+ case "j": return value / 4184
+ default: return nil
+ }
+ }
+}
diff --git a/Sources/BetterFit/Models/HealthRecord.swift b/Sources/BetterFit/Models/HealthRecord.swift
index b0092da..4d6945a 100644
--- a/Sources/BetterFit/Models/HealthRecord.swift
+++ b/Sources/BetterFit/Models/HealthRecord.swift
@@ -144,14 +144,34 @@ public struct HealthRecord: Codable, Equatable, Sendable {
// MARK: - HealthExport
-/// A parsed Apple Health export, with workouts and top-level records.
+/// A parsed Apple Health export, with workouts, records, and activity summaries.
public struct HealthExport: Codable, Equatable, Sendable {
public var workouts: [ParsedWorkout]
public var records: [HealthRecord]
+ public var activitySummaries: [HealthActivitySummary]
- public init(workouts: [ParsedWorkout] = [], records: [HealthRecord] = []) {
+ public init(
+ workouts: [ParsedWorkout] = [],
+ records: [HealthRecord] = [],
+ activitySummaries: [HealthActivitySummary] = []
+ ) {
self.workouts = workouts
self.records = records
+ self.activitySummaries = activitySummaries
+ }
+
+ // MARK: - Codable Compatibility
+
+ private enum CodingKeys: String, CodingKey {
+ case workouts, records, activitySummaries
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ workouts = try container.decode([ParsedWorkout].self, forKey: .workouts)
+ records = try container.decode([HealthRecord].self, forKey: .records)
+ activitySummaries = try container.decodeIfPresent(
+ [HealthActivitySummary].self, forKey: .activitySummaries) ?? []
}
// MARK: - Record Lookup
diff --git a/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift b/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift
index 40abb32..c00a4dd 100644
--- a/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift
+++ b/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift
@@ -5,14 +5,15 @@ import FoundationXML
// MARK: - HealthExportParser
-/// Parses workouts and top-level records from an Apple Health export
+/// Parses workouts, records, and activity summaries 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, plus the
/// top-level `Record` elements for steps, heart rate, energy, distance,
-/// body mass, and sleep.
+/// body mass, and sleep. `ActivitySummary` elements retain daily activity ring
+/// totals and goals separately from the individual records.
///
/// Example shape:
///
@@ -54,12 +55,12 @@ public enum HealthExportParser {
try parseExport(xml: xml).records
}
- /// Parse a full export, with workouts and top-level records, from XML data.
+ /// Parse workouts, records, and activity summaries 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.
+ /// Parse workouts, records, and activity summaries 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")
@@ -80,7 +81,11 @@ public enum HealthExportParser {
if let error = delegate.parseError {
throw error
}
- return HealthExport(workouts: delegate.workouts, records: delegate.records)
+ return HealthExport(
+ workouts: delegate.workouts,
+ records: delegate.records,
+ activitySummaries: delegate.activitySummaries
+ )
}
// MARK: - Date Parsing
@@ -156,6 +161,7 @@ public enum HealthExportParser {
private final class Delegate: NSObject, XMLParserDelegate {
private(set) var workouts: [ParsedWorkout] = []
private(set) var records: [HealthRecord] = []
+ private(set) var activitySummaries: [HealthActivitySummary] = []
private(set) var parseError: HealthExportParser.ParseError?
private var current: ParsedWorkout?
@@ -175,6 +181,8 @@ private final class Delegate: NSObject, XMLParserDelegate {
current?.statistics.append(stat)
case "Record":
records.append(Self.makeRecord(attributes: attributeDict))
+ case "ActivitySummary":
+ activitySummaries.append(Self.makeActivitySummary(attributes: attributeDict))
default:
break
}
@@ -198,6 +206,26 @@ private final class Delegate: NSObject, XMLParserDelegate {
// MARK: - Element Builders
+ private static func makeActivitySummary(attributes: [String: String]) -> HealthActivitySummary {
+ func number(_ key: String) -> Double? {
+ guard let value = attributes[key].flatMap(Double.init), value.isFinite, value >= 0 else {
+ return nil
+ }
+ return value
+ }
+
+ return HealthActivitySummary(
+ dateComponents: attributes["dateComponents"],
+ activeEnergyBurned: number("activeEnergyBurned"),
+ activeEnergyBurnedGoal: number("activeEnergyBurnedGoal"),
+ activeEnergyBurnedUnit: attributes["activeEnergyBurnedUnit"],
+ appleExerciseTime: number("appleExerciseTime"),
+ appleExerciseTimeGoal: number("appleExerciseTimeGoal"),
+ appleStandHours: number("appleStandHours"),
+ appleStandHoursGoal: number("appleStandHoursGoal")
+ )
+ }
+
private static func makeWorkout(attributes: [String: String]) -> ParsedWorkout {
let duration = HealthExportParser.normaliseDuration(
value: attributes["duration"].flatMap(Double.init),
diff --git a/Tests/BetterFitTests/HealthActivitySummaryTests.swift b/Tests/BetterFitTests/HealthActivitySummaryTests.swift
new file mode 100644
index 0000000..72d6448
--- /dev/null
+++ b/Tests/BetterFitTests/HealthActivitySummaryTests.swift
@@ -0,0 +1,110 @@
+import Foundation
+import Testing
+
+@testable import BetterFit
+
+struct HealthActivitySummaryTests {
+ private let summaryXML = """
+
+ """
+
+ @Test func parsesMixedExportWithoutDoubleCounting() throws {
+ let xml = """
+
+
+
+
+
+ \(summaryXML)
+
+
+ """
+ let export = try HealthExportParser.parseExport(xml: xml)
+ #expect(export.workouts.count == 1)
+ #expect(export.workouts[0].averageHeartRate == 140)
+ #expect(export.records.count == 1)
+ #expect(export.dailySummaries().first?.activeEnergyKilocalories == 100)
+ #expect(export.activitySummaries.count == 2)
+ let summary = try #require(export.activitySummaries.first)
+ #expect(summary.dateComponents == "2026-04-19")
+ #expect(summary.activeEnergyBurned == 2092)
+ #expect(summary.activeEnergyBurnedGoal == 2510.4)
+ #expect(summary.activeEnergyBurnedUnit == "kJ")
+ #expect(summary.activeEnergyKilocalories == 500)
+ #expect(abs(try #require(summary.activeEnergyGoalKilocalories) - 600) < 0.0001)
+ #expect(summary.appleExerciseTime == 42.5)
+ #expect(summary.appleExerciseTimeGoal == 30)
+ #expect(summary.appleStandHours == 13)
+ #expect(summary.appleStandHoursGoal == 12)
+ #expect(export.activitySummaries[1].dateComponents == "2026-04-20")
+ #expect(export.activitySummaries[1].activeEnergyKilocalories == 0)
+ #expect(try HealthExportParser.parseWorkouts(xml: xml).count == 1)
+ #expect(try HealthExportParser.parseRecords(xml: xml).count == 1)
+ }
+
+ @Test func parsesDataAndRoundTripsJSON() throws {
+ let xml = "\(summaryXML)"
+ let export = try HealthExportParser.parseExport(data: Data(xml.utf8))
+ #expect(try export == HealthExportParser.parseExport(xml: xml))
+ let data = try JSONEncoder().encode(export)
+ #expect(try JSONDecoder().decode(HealthExport.self, from: data) == export)
+ }
+
+ @Test func decodesExportsFromBeforeActivitySummaries() throws {
+ let data = Data(#"{"workouts":[],"records":[]}"#.utf8)
+ #expect(try JSONDecoder().decode(HealthExport.self, from: data) == HealthExport())
+ }
+
+ @Test func leavesMissingValuesNil() throws {
+ let export = try HealthExportParser.parseExport(xml: "")
+ #expect(export.activitySummaries == [HealthActivitySummary()])
+ #expect(try HealthExportParser.parseExport(xml: "").activitySummaries.isEmpty)
+ }
+
+ @Test(arguments: ["oops", "NaN", "inf", "-1", ""])
+ func rejectsInvalidNumericValues(value: String) throws {
+ let xml = """
+
+
+ """
+ let summaries = try HealthExportParser.parseExport(xml: xml).activitySummaries
+ #expect(summaries == [HealthActivitySummary(dateComponents: "2026-04-19")])
+ }
+
+ @Test(arguments: ["kcal", "KCAL", "kJ", "J"])
+ func normalisesSupportedEnergyUnits(unit: String) throws {
+ let value: Double = switch unit.lowercased() {
+ case "kj": 4.184
+ case "j": 4184
+ default: 1
+ }
+ let summary = HealthActivitySummary(
+ activeEnergyBurned: value, activeEnergyBurnedGoal: value * 2, activeEnergyBurnedUnit: unit)
+ #expect(abs(try #require(summary.activeEnergyKilocalories) - 1) < 0.0001)
+ #expect(abs(try #require(summary.activeEnergyGoalKilocalories) - 2) < 0.0001)
+ }
+
+ @Test(arguments: [nil, "", "unknown"] as [String?])
+ func preservesUnsupportedUnitsWithoutInventingKilocalories(unit: String?) {
+ let summary = HealthActivitySummary(
+ activeEnergyBurned: 100, activeEnergyBurnedGoal: 200, activeEnergyBurnedUnit: unit)
+ #expect(summary.activeEnergyBurned == 100)
+ #expect(summary.activeEnergyBurnedUnit == unit)
+ #expect(summary.activeEnergyKilocalories == nil)
+ #expect(summary.activeEnergyGoalKilocalories == nil)
+ }
+
+ @Test func rejectsMalformedXML() {
+ #expect(throws: HealthExportParser.ParseError.self) {
+ try HealthExportParser.parseExport(xml: "