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