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
118 changes: 118 additions & 0 deletions Sources/BetterFit/Models/WeeklyTrainingSummary.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
181 changes: 181 additions & 0 deletions Tests/BetterFitTests/WeeklyTrainingSummaryTests.swift
Original file line number Diff line number Diff line change
@@ -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 = """
<HealthData locale="en_AU">
<Workout workoutActivityType="HKWorkoutActivityTypeRunning"
duration="30" durationUnit="min"
totalDistance="5" totalDistanceUnit="km"
totalEnergyBurned="300" totalEnergyBurnedUnit="kcal"
startDate="2026-04-19 07:00:00 +0000"
endDate="2026-04-19 07:30:00 +0000">
<WorkoutStatistics type="HKQuantityTypeIdentifierHeartRate"
average="155" minimum="105" maximum="178" unit="count/min"
startDate="2026-04-19 07:00:00 +0000"
endDate="2026-04-19 07:30:00 +0000"/>
</Workout>
</HealthData>
"""
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)
}
}
Loading