Skip to content
Open
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
153 changes: 153 additions & 0 deletions Sources/BetterFit/Models/DailyHealthSummary.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
176 changes: 176 additions & 0 deletions Tests/BetterFitTests/DailyHealthSummaryTests.swift
Original file line number Diff line number Diff line change
@@ -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 = """
<HealthData locale="en_AU">
<Record type="HKQuantityTypeIdentifierStepCount" sourceName="iPhone"
unit="count" value="8421"
startDate="2026-04-19 00:00:00 +0000" endDate="2026-04-19 23:59:59 +0000"/>
<Record type="HKQuantityTypeIdentifierStepCount" sourceName="iPhone"
unit="count" value="1000"
startDate="2026-04-20 00:00:00 +0000" endDate="2026-04-20 23:59:59 +0000"/>
</HealthData>
"""
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)
}
}
Loading