Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions Sources/BetterFit/Models/HealthActivitySummary.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
24 changes: 22 additions & 2 deletions Sources/BetterFit/Models/HealthRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 33 additions & 5 deletions Sources/BetterFit/Services/HealthKit/HealthExportParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
///
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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?

Expand All @@ -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
}
Expand All @@ -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),
Expand Down
110 changes: 110 additions & 0 deletions Tests/BetterFitTests/HealthActivitySummaryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import Foundation
import Testing

@testable import BetterFit

struct HealthActivitySummaryTests {
private let summaryXML = """
<ActivitySummary dateComponents="2026-04-19"
activeEnergyBurned="2092" activeEnergyBurnedGoal="2510.4" activeEnergyBurnedUnit="kJ"
appleExerciseTime="42.5" appleExerciseTimeGoal="30"
appleStandHours="13" appleStandHoursGoal="12"/>
"""

@Test func parsesMixedExportWithoutDoubleCounting() throws {
let xml = """
<HealthData>
<Record type="HKQuantityTypeIdentifierActiveEnergyBurned" value="100" unit="kcal"
startDate="2026-04-19 07:00:00 +1000"/>
<Workout workoutActivityType="HKWorkoutActivityTypeRunning" duration="30" durationUnit="min">
<WorkoutStatistics type="HKQuantityTypeIdentifierHeartRate" average="140" unit="count/min"/>
</Workout>
\(summaryXML)
<ActivitySummary dateComponents="2026-04-20" activeEnergyBurned="0"
activeEnergyBurnedUnit="kcal"/>
</HealthData>
"""
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 = "<HealthData>\(summaryXML)</HealthData>"
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: "<HealthData><ActivitySummary/></HealthData>")
#expect(export.activitySummaries == [HealthActivitySummary()])
#expect(try HealthExportParser.parseExport(xml: "<HealthData/>").activitySummaries.isEmpty)
}

@Test(arguments: ["oops", "NaN", "inf", "-1", ""])
func rejectsInvalidNumericValues(value: String) throws {
let xml = """
<HealthData><ActivitySummary dateComponents="2026-04-19"
activeEnergyBurned="\(value)" activeEnergyBurnedGoal="\(value)"
appleExerciseTime="\(value)" appleExerciseTimeGoal="\(value)"
appleStandHours="\(value)" appleStandHoursGoal="\(value)"/>
</HealthData>
"""
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: "<HealthData><ActivitySummary")
}
}
}
18 changes: 18 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ Returns the current auto-tracking status.

## Models

### Apple Health exports

`HealthExportParser.parseExport(data:)` and `parseExport(xml:)` return a
`HealthExport` containing `workouts`, `records`, and `activitySummaries`.
The parser uses Foundation and does not require HealthKit or UI frameworks.

Each `HealthActivitySummary` preserves the exported `dateComponents` day
string (`yyyy-MM-dd`), active energy and goal with their original unit,
exercise time and goal in minutes, and stand hours and goal. Missing or
invalid numeric attributes are nil; measured zero remains zero.
`activeEnergyKilocalories` and `activeEnergyGoalKilocalories` convert kcal,
kJ, and J to kilocalories, returning nil for missing or unsupported units.

Activity summaries remain separate from `dailySummaries(calendar:)`, which
aggregates records. Adding the two energy totals would count the same
activity twice. Older encoded exports without `activitySummaries` decode
with an empty collection.

### `Exercise`

Represents a single exercise.
Expand Down
Loading