diff --git a/Sources/BetterFit/Models/DailyHealthSummary.swift b/Sources/BetterFit/Models/DailyHealthSummary.swift new file mode 100644 index 0000000..83d0a6d --- /dev/null +++ b/Sources/BetterFit/Models/DailyHealthSummary.swift @@ -0,0 +1,153 @@ +import Foundation + +// MARK: - DailyHealthSummary + +/// One calendar day of health data, aggregated from top-level records. +/// +/// Totals use the normalised values on `HealthRecord`, so energy is in +/// kilocalories, distance in metres, and body mass in kilograms regardless +/// of the unit each record was exported with. Fields with no records that +/// day stay nil rather than reading as a measured zero. +public struct DailyHealthSummary: Codable, Equatable, Sendable { + /// Start of the day the summary covers, in the aggregating calendar. + public var date: Date + public var stepCount: Double? + public var activeEnergyKilocalories: Double? + public var basalEnergyKilocalories: Double? + public var distanceMetres: Double? + /// Mean of the day heart rate samples. + public var averageHeartRate: Double? + /// Most recent resting heart rate sample of the day. + public var restingHeartRate: Double? + /// Most recent body mass sample of the day, in kilograms. + public var bodyMassKilograms: Double? + /// Total time covered by sleep analysis records starting that day. + public var sleepDuration: TimeInterval? + + public init( + date: Date, + stepCount: Double? = nil, + activeEnergyKilocalories: Double? = nil, + basalEnergyKilocalories: Double? = nil, + distanceMetres: Double? = nil, + averageHeartRate: Double? = nil, + restingHeartRate: Double? = nil, + bodyMassKilograms: Double? = nil, + sleepDuration: TimeInterval? = nil + ) { + self.date = date + self.stepCount = stepCount + self.activeEnergyKilocalories = activeEnergyKilocalories + self.basalEnergyKilocalories = basalEnergyKilocalories + self.distanceMetres = distanceMetres + self.averageHeartRate = averageHeartRate + self.restingHeartRate = restingHeartRate + self.bodyMassKilograms = bodyMassKilograms + self.sleepDuration = sleepDuration + } +} + +// MARK: - Aggregation + +extension HealthExport { + + /// Aggregate records into one summary per calendar day, sorted by day. + /// + /// Records are grouped by the start of the day of their `startDate` in + /// `calendar`. Records without a start date cannot be placed on a day + /// and are skipped. + public func dailySummaries(calendar: Calendar = .current) -> [DailyHealthSummary] { + var days: [Date: DayAccumulator] = [:] + + for record in records { + guard let startDate = record.startDate else { continue } + let day = calendar.startOfDay(for: startDate) + days[day, default: DayAccumulator()].add(record) + } + + return days.keys.sorted().map { day in + // Force unwrap is safe: the key came from the dictionary. + days[day]!.summary(date: day) + } + } +} + +// MARK: - Day Accumulator + +private struct DayAccumulator { + private var stepCount: Double? + private var activeEnergy: Double? + private var basalEnergy: Double? + private var distance: Double? + private var heartRateSum: Double = 0 + private var heartRateCount: Int = 0 + private var resting: (date: Date, value: Double)? + private var bodyMass: (date: Date, value: Double)? + private var sleep: TimeInterval? + + mutating func add(_ record: HealthRecord) { + addTotal(record) + addSample(record) + } + + private mutating func addTotal(_ record: HealthRecord) { + switch record.kind { + case .stepCount: + if let value = record.value { stepCount = (stepCount ?? 0) + value } + case .activeEnergy: + if let value = record.valueKilocalories { + activeEnergy = (activeEnergy ?? 0) + value + } + case .basalEnergy: + if let value = record.valueKilocalories { + basalEnergy = (basalEnergy ?? 0) + value + } + case .distanceWalkingRunning, .distanceCycling: + if let value = record.valueMetres { distance = (distance ?? 0) + value } + default: + break + } + } + + private mutating func addSample(_ record: HealthRecord) { + switch record.kind { + case .heartRate: + if let value = record.value { + heartRateSum += value + heartRateCount += 1 + } + case .restingHeartRate: + resting = latest(resting, record: record, value: record.value) + case .bodyMass: + bodyMass = latest(bodyMass, record: record, value: record.valueKilograms) + case .sleepAnalysis: + if let duration = record.duration { sleep = (sleep ?? 0) + duration } + default: + break + } + } + + private func latest( + _ current: (date: Date, value: Double)?, + record: HealthRecord, + value: Double? + ) -> (date: Date, value: Double)? { + guard let value, let date = record.startDate else { return current } + guard let current else { return (date, value) } + return date >= current.date ? (date, value) : current + } + + func summary(date: Date) -> DailyHealthSummary { + DailyHealthSummary( + date: date, + stepCount: stepCount, + activeEnergyKilocalories: activeEnergy, + basalEnergyKilocalories: basalEnergy, + distanceMetres: distance, + averageHeartRate: heartRateCount > 0 ? heartRateSum / Double(heartRateCount) : nil, + restingHeartRate: resting?.value, + bodyMassKilograms: bodyMass?.value, + sleepDuration: sleep + ) + } +} diff --git a/Tests/BetterFitTests/DailyHealthSummaryTests.swift b/Tests/BetterFitTests/DailyHealthSummaryTests.swift new file mode 100644 index 0000000..9fdc26b --- /dev/null +++ b/Tests/BetterFitTests/DailyHealthSummaryTests.swift @@ -0,0 +1,176 @@ +import XCTest + +@testable import BetterFit + +final class DailyHealthSummaryTests: XCTestCase { + + // MARK: - Fixtures + + private var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private func date(_ string: String) -> Date { + 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.date(from: string)! + } + + private func record( + _ type: String, + unit: String? = nil, + start: String, + end: String? = nil, + value: Double? = nil, + categoryValue: String? = nil + ) -> HealthRecord { + HealthRecord( + type: type, + unit: unit, + startDate: date(start), + endDate: date(end ?? start), + value: value, + categoryValue: categoryValue + ) + } + + private func export(_ records: [HealthRecord]) -> HealthExport { + HealthExport(workouts: [], records: records) + } + + // MARK: - Grouping + + func testGroupsRecordsByDayAndSorts() { + let summaries = export([ + record("HKQuantityTypeIdentifierStepCount", unit: "count", start: "2026-04-20 09:00:00", value: 100), + record("HKQuantityTypeIdentifierStepCount", unit: "count", start: "2026-04-19 09:00:00", value: 200) + ]).dailySummaries(calendar: utcCalendar) + + XCTAssertEqual(summaries.count, 2) + XCTAssertEqual(summaries[0].date, date("2026-04-19 00:00:00")) + XCTAssertEqual(summaries[0].stepCount ?? 0, 200, accuracy: 0.001) + XCTAssertEqual(summaries[1].stepCount ?? 0, 100, accuracy: 0.001) + } + + func testEmptyExportReturnsNoSummaries() { + XCTAssertTrue(export([]).dailySummaries(calendar: utcCalendar).isEmpty) + } + + func testRecordsWithoutStartDateAreSkipped() { + let undated = HealthRecord(type: "HKQuantityTypeIdentifierStepCount", unit: "count", value: 500) + XCTAssertTrue(export([undated]).dailySummaries(calendar: utcCalendar).isEmpty) + } + + // MARK: - Totals + + func testSumsStepsEnergyAndDistanceWithNormalisation() throws { + let summaries = export([ + record("HKQuantityTypeIdentifierStepCount", unit: "count", start: "2026-04-19 08:00:00", value: 4000), + record("HKQuantityTypeIdentifierStepCount", unit: "count", start: "2026-04-19 12:00:00", value: 4421), + record( + "HKQuantityTypeIdentifierActiveEnergyBurned", unit: "kJ", + start: "2026-04-19 08:00:00", value: 2092), + record( + "HKQuantityTypeIdentifierBasalEnergyBurned", unit: "kcal", + start: "2026-04-19 08:00:00", value: 1600), + record( + "HKQuantityTypeIdentifierDistanceWalkingRunning", unit: "km", + start: "2026-04-19 08:00:00", value: 2), + record( + "HKQuantityTypeIdentifierDistanceCycling", unit: "m", + start: "2026-04-19 09:00:00", value: 500) + ]).dailySummaries(calendar: utcCalendar) + + let day = try XCTUnwrap(summaries.first) + XCTAssertEqual(day.stepCount ?? 0, 8421, accuracy: 0.001) + // 2092 kJ is 500 kcal + XCTAssertEqual(day.activeEnergyKilocalories ?? 0, 500, accuracy: 0.001) + XCTAssertEqual(day.basalEnergyKilocalories ?? 0, 1600, accuracy: 0.001) + XCTAssertEqual(day.distanceMetres ?? 0, 2500, accuracy: 0.001) + } + + func testMissingKindsStayNil() throws { + let summaries = export([ + record("HKQuantityTypeIdentifierStepCount", unit: "count", start: "2026-04-19 08:00:00", value: 10) + ]).dailySummaries(calendar: utcCalendar) + + let day = try XCTUnwrap(summaries.first) + XCTAssertNil(day.activeEnergyKilocalories) + XCTAssertNil(day.averageHeartRate) + XCTAssertNil(day.sleepDuration) + XCTAssertNil(day.bodyMassKilograms) + } + + // MARK: - Heart Rate and Body Mass + + func testAverageHeartRateAndLatestRestingAndMass() throws { + let summaries = export([ + record("HKQuantityTypeIdentifierHeartRate", unit: "count/min", start: "2026-04-19 07:00:00", value: 100), + record("HKQuantityTypeIdentifierHeartRate", unit: "count/min", start: "2026-04-19 08:00:00", value: 140), + record( + "HKQuantityTypeIdentifierRestingHeartRate", unit: "count/min", + start: "2026-04-19 06:00:00", value: 62), + record( + "HKQuantityTypeIdentifierRestingHeartRate", unit: "count/min", + start: "2026-04-19 22:00:00", value: 58), + record("HKQuantityTypeIdentifierBodyMass", unit: "kg", start: "2026-04-19 06:00:00", value: 82), + record("HKQuantityTypeIdentifierBodyMass", unit: "lb", start: "2026-04-19 21:00:00", value: 180) + ]).dailySummaries(calendar: utcCalendar) + + let day = try XCTUnwrap(summaries.first) + XCTAssertEqual(day.averageHeartRate ?? 0, 120, accuracy: 0.001) + XCTAssertEqual(day.restingHeartRate ?? 0, 58, accuracy: 0.001) + // 180 lb is 81.6466 kg, and it is the later sample + XCTAssertEqual(day.bodyMassKilograms ?? 0, 81.6466, accuracy: 0.001) + } + + // MARK: - Sleep + + func testSleepDurationSumsStagesStartingThatDay() throws { + let summaries = export([ + record( + "HKCategoryTypeIdentifierSleepAnalysis", start: "2026-04-19 01:00:00", + end: "2026-04-19 04:00:00", categoryValue: "HKCategoryValueSleepAnalysisAsleepCore"), + record( + "HKCategoryTypeIdentifierSleepAnalysis", start: "2026-04-19 04:00:00", + end: "2026-04-19 06:00:00", categoryValue: "HKCategoryValueSleepAnalysisAsleepDeep") + ]).dailySummaries(calendar: utcCalendar) + + let day = try XCTUnwrap(summaries.first) + // 3 hours plus 2 hours + XCTAssertEqual(day.sleepDuration ?? 0, 18000, accuracy: 0.001) + } + + // MARK: - End to End + + func testDailySummariesFromParsedExport() throws { + let xml = """ + + + + + """ + let parsed = try HealthExportParser.parseExport(xml: xml) + let summaries = parsed.dailySummaries(calendar: utcCalendar) + XCTAssertEqual(summaries.count, 2) + XCTAssertEqual(summaries[0].stepCount ?? 0, 8421, accuracy: 0.001) + XCTAssertEqual(summaries[1].stepCount ?? 0, 1000, accuracy: 0.001) + } + + func testCodableRoundTrip() throws { + let summaries = export([ + record("HKQuantityTypeIdentifierStepCount", unit: "count", start: "2026-04-19 08:00:00", value: 10) + ]).dailySummaries(calendar: utcCalendar) + let data = try JSONEncoder().encode(summaries) + let decoded = try JSONDecoder().decode([DailyHealthSummary].self, from: data) + XCTAssertEqual(decoded, summaries) + } +}