From 6719b9b810fd944430ec65551059fcf8fb8c3c1d Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:26:29 +0900 Subject: [PATCH] feat(health): add weekly training summaries from parsed workouts --- .../Models/WeeklyTrainingSummary.swift | 118 ++++++++++++ .../WeeklyTrainingSummaryTests.swift | 181 ++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 Sources/BetterFit/Models/WeeklyTrainingSummary.swift create mode 100644 Tests/BetterFitTests/WeeklyTrainingSummaryTests.swift diff --git a/Sources/BetterFit/Models/WeeklyTrainingSummary.swift b/Sources/BetterFit/Models/WeeklyTrainingSummary.swift new file mode 100644 index 0000000..6d99784 --- /dev/null +++ b/Sources/BetterFit/Models/WeeklyTrainingSummary.swift @@ -0,0 +1,118 @@ +import Foundation + +// MARK: - WeeklyTrainingSummary + +/// One calendar week of training, aggregated from parsed workouts. +/// +/// Totals use the normalised values on `ParsedWorkout`, so energy is in +/// kilocalories and distance in metres regardless of the unit each workout +/// was exported with. Fields with no workouts carrying that metric stay nil +/// rather than reading as a measured zero. +public struct WeeklyTrainingSummary: Codable, Equatable, Sendable { + /// Start of the week the summary covers, in the aggregating calendar. + public var weekStart: Date + public var workoutCount: Int + public var totalDuration: TimeInterval? + public var totalActiveEnergyKilocalories: Double? + public var totalDistanceMetres: Double? + /// Mean of the per-workout average heart rates, unweighted by duration. + public var averageHeartRate: Double? + /// Highest per-workout maximum heart rate in the week. + public var maximumHeartRate: Double? + /// Workout count per raw activity type, for example running or cycling. + public var activityCounts: [String: Int] + + public init( + weekStart: Date, + workoutCount: Int = 0, + totalDuration: TimeInterval? = nil, + totalActiveEnergyKilocalories: Double? = nil, + totalDistanceMetres: Double? = nil, + averageHeartRate: Double? = nil, + maximumHeartRate: Double? = nil, + activityCounts: [String: Int] = [:] + ) { + self.weekStart = weekStart + self.workoutCount = workoutCount + self.totalDuration = totalDuration + self.totalActiveEnergyKilocalories = totalActiveEnergyKilocalories + self.totalDistanceMetres = totalDistanceMetres + self.averageHeartRate = averageHeartRate + self.maximumHeartRate = maximumHeartRate + self.activityCounts = activityCounts + } +} + +// MARK: - Aggregation + +extension HealthExport { + + /// Aggregate workouts into one summary per calendar week, sorted by week. + /// + /// Workouts are grouped by the week containing their `startDate` in + /// `calendar`. Workouts without a start date cannot be placed in a week + /// and are skipped. + public func weeklyTrainingSummaries(calendar: Calendar = .current) -> [WeeklyTrainingSummary] { + var weeks: [Date: WeekAccumulator] = [:] + + for workout in workouts { + guard let startDate = workout.startDate else { continue } + guard let weekStart = calendar.dateInterval(of: .weekOfYear, for: startDate)?.start else { + continue + } + weeks[weekStart, default: WeekAccumulator()].add(workout) + } + + return weeks.keys.sorted().map { week in + // Force unwrap is safe: the key came from the dictionary. + weeks[week]!.summary(weekStart: week) + } + } +} + +// MARK: - Week Accumulator + +private struct WeekAccumulator { + private var workoutCount: Int = 0 + private var totalDuration: TimeInterval? + private var totalEnergy: Double? + private var totalDistance: Double? + private var heartRateSum: Double = 0 + private var heartRateCount: Int = 0 + private var maximumHeartRate: Double? + private var activityCounts: [String: Int] = [:] + + mutating func add(_ workout: ParsedWorkout) { + workoutCount += 1 + activityCounts[workout.activityType, default: 0] += 1 + if let duration = workout.duration { + totalDuration = (totalDuration ?? 0) + duration + } + if let energy = workout.activeEnergyKilocalories { + totalEnergy = (totalEnergy ?? 0) + energy + } + if let distance = workout.distanceMetres { + totalDistance = (totalDistance ?? 0) + distance + } + if let average = workout.averageHeartRate { + heartRateSum += average + heartRateCount += 1 + } + if let maximum = workout.maximumHeartRate { + maximumHeartRate = max(maximumHeartRate ?? maximum, maximum) + } + } + + func summary(weekStart: Date) -> WeeklyTrainingSummary { + WeeklyTrainingSummary( + weekStart: weekStart, + workoutCount: workoutCount, + totalDuration: totalDuration, + totalActiveEnergyKilocalories: totalEnergy, + totalDistanceMetres: totalDistance, + averageHeartRate: heartRateCount > 0 ? heartRateSum / Double(heartRateCount) : nil, + maximumHeartRate: maximumHeartRate, + activityCounts: activityCounts + ) + } +} diff --git a/Tests/BetterFitTests/WeeklyTrainingSummaryTests.swift b/Tests/BetterFitTests/WeeklyTrainingSummaryTests.swift new file mode 100644 index 0000000..10f5256 --- /dev/null +++ b/Tests/BetterFitTests/WeeklyTrainingSummaryTests.swift @@ -0,0 +1,181 @@ +import XCTest + +@testable import BetterFit + +final class WeeklyTrainingSummaryTests: XCTestCase { + + // MARK: - Fixtures + + private var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + calendar.firstWeekday = 2 + 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 workout( + _ activityType: String, + start: String? = nil, + duration: TimeInterval? = nil, + totalEnergyBurned: Double? = nil, + totalDistance: Double? = nil, + statistics: [WorkoutStatistics] = [] + ) -> ParsedWorkout { + ParsedWorkout( + activityType: activityType, + startDate: start.map { date($0) }, + duration: duration, + totalEnergyBurned: totalEnergyBurned, + totalDistance: totalDistance, + statistics: statistics + ) + } + + private func export(_ workouts: [ParsedWorkout]) -> HealthExport { + HealthExport(workouts: workouts, records: []) + } + + // MARK: - Grouping + + func testGroupsWorkoutsByWeekAndSorts() { + let summaries = export([ + workout("HKWorkoutActivityTypeRunning", start: "2026-04-22 07:00:00"), + workout("HKWorkoutActivityTypeRunning", start: "2026-04-14 07:00:00"), + workout("HKWorkoutActivityTypeCycling", start: "2026-04-15 07:00:00") + ]).weeklyTrainingSummaries(calendar: utcCalendar) + + XCTAssertEqual(summaries.count, 2) + XCTAssertEqual(summaries[0].weekStart, date("2026-04-13 00:00:00")) + XCTAssertEqual(summaries[0].workoutCount, 2) + XCTAssertEqual(summaries[1].weekStart, date("2026-04-20 00:00:00")) + XCTAssertEqual(summaries[1].workoutCount, 1) + } + + func testEmptyExportReturnsNoSummaries() { + XCTAssertTrue(export([]).weeklyTrainingSummaries(calendar: utcCalendar).isEmpty) + } + + func testWorkoutsWithoutStartDateAreSkipped() { + let undated = workout("HKWorkoutActivityTypeRunning") + XCTAssertTrue(export([undated]).weeklyTrainingSummaries(calendar: utcCalendar).isEmpty) + } + + // MARK: - Totals + + func testSumsDurationEnergyAndDistanceWithNormalisation() throws { + let run = workout( + "HKWorkoutActivityTypeRunning", start: "2026-04-14 07:00:00", duration: 1800, + statistics: [ + WorkoutStatistics( + type: "HKQuantityTypeIdentifierActiveEnergyBurned", sum: 418.4, unit: "kJ"), + WorkoutStatistics( + type: "HKQuantityTypeIdentifierDistanceWalkingRunning", sum: 5, unit: "km") + ]) + let ride = workout( + "HKWorkoutActivityTypeCycling", start: "2026-04-15 07:00:00", duration: 3600, + totalEnergyBurned: 500, totalDistance: 20000) + + let summaries = export([run, ride]).weeklyTrainingSummaries(calendar: utcCalendar) + let week = try XCTUnwrap(summaries.first) + XCTAssertEqual(week.workoutCount, 2) + XCTAssertEqual(week.totalDuration ?? 0, 5400, accuracy: 0.001) + // Run energy is 418.4 kJ, which is 100 kcal. Ride falls back to its total. + XCTAssertEqual(week.totalActiveEnergyKilocalories ?? 0, 600, accuracy: 0.001) + XCTAssertEqual(week.totalDistanceMetres ?? 0, 25000, accuracy: 0.001) + } + + func testMissingMetricsStayNil() throws { + let summaries = export([ + workout("HKWorkoutActivityTypeYoga", start: "2026-04-14 07:00:00") + ]).weeklyTrainingSummaries(calendar: utcCalendar) + + let week = try XCTUnwrap(summaries.first) + XCTAssertEqual(week.workoutCount, 1) + XCTAssertNil(week.totalDuration) + XCTAssertNil(week.totalActiveEnergyKilocalories) + XCTAssertNil(week.totalDistanceMetres) + XCTAssertNil(week.averageHeartRate) + XCTAssertNil(week.maximumHeartRate) + } + + // MARK: - Heart Rate and Activities + + func testAverageAndMaximumHeartRate() throws { + func heartRateWorkout(_ start: String, average: Double, maximum: Double) -> ParsedWorkout { + workout( + "HKWorkoutActivityTypeRunning", start: start, + statistics: [ + WorkoutStatistics( + type: "HKQuantityTypeIdentifierHeartRate", + average: average, minimum: 90, maximum: maximum, unit: "count/min") + ]) + } + + let summaries = export([ + heartRateWorkout("2026-04-14 07:00:00", average: 140, maximum: 170), + heartRateWorkout("2026-04-15 07:00:00", average: 160, maximum: 185), + workout("HKWorkoutActivityTypeYoga", start: "2026-04-16 07:00:00") + ]).weeklyTrainingSummaries(calendar: utcCalendar) + + let week = try XCTUnwrap(summaries.first) + XCTAssertEqual(week.averageHeartRate ?? 0, 150, accuracy: 0.001) + XCTAssertEqual(week.maximumHeartRate ?? 0, 185, accuracy: 0.001) + } + + func testActivityCounts() throws { + let summaries = export([ + workout("HKWorkoutActivityTypeRunning", start: "2026-04-14 07:00:00"), + workout("HKWorkoutActivityTypeRunning", start: "2026-04-15 07:00:00"), + workout("HKWorkoutActivityTypeCycling", start: "2026-04-16 07:00:00") + ]).weeklyTrainingSummaries(calendar: utcCalendar) + + let week = try XCTUnwrap(summaries.first) + XCTAssertEqual(week.activityCounts["HKWorkoutActivityTypeRunning"], 2) + XCTAssertEqual(week.activityCounts["HKWorkoutActivityTypeCycling"], 1) + } + + // MARK: - End to End + + func testWeeklySummariesFromParsedExport() throws { + let xml = """ + + + + + + """ + let parsed = try HealthExportParser.parseExport(xml: xml) + let summaries = parsed.weeklyTrainingSummaries(calendar: utcCalendar) + + XCTAssertEqual(summaries.count, 1) + XCTAssertEqual(summaries[0].weekStart, date("2026-04-13 00:00:00")) + XCTAssertEqual(summaries[0].workoutCount, 1) + XCTAssertEqual(summaries[0].totalActiveEnergyKilocalories ?? 0, 300, accuracy: 0.001) + XCTAssertEqual(summaries[0].averageHeartRate ?? 0, 155, accuracy: 0.001) + } + + func testCodableRoundTrip() throws { + let summaries = export([ + workout("HKWorkoutActivityTypeRunning", start: "2026-04-14 07:00:00", duration: 60) + ]).weeklyTrainingSummaries(calendar: utcCalendar) + let data = try JSONEncoder().encode(summaries) + let decoded = try JSONDecoder().decode([WeeklyTrainingSummary].self, from: data) + XCTAssertEqual(decoded, summaries) + } +}