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
67 changes: 67 additions & 0 deletions Sources/BetterFit/Models/WeeklyTrainingSummaryExport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Foundation

// MARK: - WeeklyTrainingSummary Export

extension Array where Element == WeeklyTrainingSummary {

/// CSV header shared by every exported file.
public static var weeklyTrainingSummaryCSVHeader: String {
"week_start,workout_count,duration_seconds,active_energy_kcal,distance_metres,"
+ "average_heart_rate,maximum_heart_rate,activity_counts"
}

/// Encode one row per week in array order, with ISO8601 dates and a final newline.
/// Missing metrics are empty fields. Activity counts are a sorted JSON object
/// inside a quoted CSV field, with embedded quotes escaped by doubling them.
public func csvString() throws -> String {
var lines = [Self.weeklyTrainingSummaryCSVHeader]
let formatter = ISO8601DateFormatter()
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]

for summary in self {
// JSONEncoder always produces valid UTF-8.
let counts = String(data: try encoder.encode(summary.activityCounts), encoding: .utf8)!
let countsField = "\"" + counts.replacingOccurrences(of: "\"", with: "\"\"") + "\""
lines.append([
formatter.string(from: summary.weekStart),
String(summary.workoutCount),
Self.csvNumber(summary.totalDuration),
Self.csvNumber(summary.totalActiveEnergyKilocalories),
Self.csvNumber(summary.totalDistanceMetres),
Self.csvNumber(summary.averageHeartRate),
Self.csvNumber(summary.maximumHeartRate),
countsField
].joined(separator: ","))
}

return lines.joined(separator: "\n") + "\n"
}

/// Encode pretty printed JSON with sorted keys and ISO8601 dates.
/// Missing metrics are omitted, while measured zero remains zero.
public func jsonData() throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(self)
}

/// Write CSV to `url`, atomically creating or replacing the file.
public func writeCSV(to url: URL) throws {
try csvString().write(to: url, atomically: true, encoding: .utf8)
}

/// Write JSON to `url`, atomically creating or replacing the file.
public func writeJSON(to url: URL) throws {
try jsonData().write(to: url, options: .atomic)
}

private static func csvNumber(_ value: Double?) -> String {
guard let value else { return "" }
if value == value.rounded(), abs(value) < 1e15 {
return String(Int(value))
}
return String(value)
}
}
169 changes: 169 additions & 0 deletions Tests/BetterFitTests/WeeklyTrainingSummaryExportTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import XCTest

import BetterFit

final class WeeklyTrainingSummaryExportTests: XCTestCase {

// MARK: - Fixtures

private let weekStart = Date(timeIntervalSince1970: 1776038400) // 13 April 2026, UTC

private var sample: [WeeklyTrainingSummary] {
[WeeklyTrainingSummary(
weekStart: weekStart,
workoutCount: 3,
totalDuration: 5400,
totalActiveEnergyKilocalories: 650.5,
totalDistanceMetres: 12000,
averageHeartRate: 145.5,
maximumHeartRate: 180,
activityCounts: ["Running": 2, "Cycling": 1]
)]
}

private var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}

// MARK: - CSV

func testCSVHeaderUnitsAndSortedActivityCounts() throws {
let expected = "week_start,workout_count,duration_seconds,active_energy_kcal,distance_metres,"
+ "average_heart_rate,maximum_heart_rate,activity_counts\n"
+ #"2026-04-13T00:00:00Z,3,5400,650.5,12000,145.5,180,"{""Cycling"":1,""Running"":2}""# + "\n"

XCTAssertEqual(try sample.csvString(), expected)
}

func testCSVDistinguishesMissingMetricsFromMeasuredZero() throws {
let summaries = [
WeeklyTrainingSummary(weekStart: weekStart),
WeeklyTrainingSummary(
weekStart: weekStart, totalDuration: 0, totalActiveEnergyKilocalories: 0,
totalDistanceMetres: 0, averageHeartRate: 0, maximumHeartRate: 0
)
]
let rows = try summaries.csvString().split(separator: "\n")

XCTAssertEqual(rows[1], #"2026-04-13T00:00:00Z,0,,,,,,"{}""#)
XCTAssertEqual(rows[2], #"2026-04-13T00:00:00Z,0,0,0,0,0,0,"{}""#)
}

func testCSVEmptyArrayIsHeaderOnly() throws {
XCTAssertEqual(
try [WeeklyTrainingSummary]().csvString(),
[WeeklyTrainingSummary].weeklyTrainingSummaryCSVHeader + "\n"
)
}

func testCSVPreservesArrayOrder() throws {
let summaries = [
WeeklyTrainingSummary(weekStart: weekStart.addingTimeInterval(7 * 86400)),
sample[0]
]
let rows = try summaries.csvString().split(separator: "\n")

XCTAssertTrue(rows[1].hasPrefix("2026-04-20T00:00:00Z,"))
XCTAssertTrue(rows[2].hasPrefix("2026-04-13T00:00:00Z,"))
}

func testCSVQuotesActivityNamesContainingCSVDelimitersAndUnicode() throws {
let summary = WeeklyTrainingSummary(
weekStart: weekStart, workoutCount: 2,
activityCounts: ["Run, \"fast\"\n室": 2]
)
let rows = try [summary].csvString().split(separator: "\n")

XCTAssertEqual(rows.count, 2)
XCTAssertEqual(rows[1], #"2026-04-13T00:00:00Z,2,,,,,,"{""Run, \""fast\""\n室"":2}""#)
}

// MARK: - JSON

func testJSONRoundTripPreservesOrderActivityNamesAndMissingMetrics() throws {
let summaries = sample + [WeeklyTrainingSummary(
weekStart: weekStart.addingTimeInterval(7 * 86400),
totalDuration: 0, activityCounts: ["Run, \"fast\"\n室": 2]
)]
let data = try summaries.jsonData()
let decoded = try decoder.decode([WeeklyTrainingSummary].self, from: data)
XCTAssertEqual(decoded, summaries)

let objects = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [[String: Any]])
XCTAssertEqual(objects[0]["weekStart"] as? String, "2026-04-13T00:00:00Z")
XCTAssertEqual(objects[1]["totalDuration"] as? Double, 0)
XCTAssertNil(objects[1]["averageHeartRate"])
XCTAssertNil(objects[1]["totalActiveEnergyKilocalories"])
}

func testJSONEmptyArray() throws {
let data = try [WeeklyTrainingSummary]().jsonData()
XCTAssertEqual(try decoder.decode([WeeklyTrainingSummary].self, from: data), [])
}

func testJSONRejectsNonFiniteMetrics() {
let summaries = [WeeklyTrainingSummary(weekStart: weekStart, totalDuration: .infinity)]
XCTAssertThrowsError(try summaries.jsonData())
}

// MARK: - File Writing

func testWritesAndReplacesCSVAndJSONFiles() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }

let csvURL = directory.appendingPathComponent("weekly.csv")
let jsonURL = directory.appendingPathComponent("weekly.json")
try "old contents".write(to: csvURL, atomically: true, encoding: .utf8)
try "old contents".write(to: jsonURL, atomically: true, encoding: .utf8)

try sample.writeCSV(to: csvURL)
try sample.writeJSON(to: jsonURL)

XCTAssertEqual(try String(contentsOf: csvURL, encoding: .utf8), try sample.csvString())
XCTAssertEqual(try decoder.decode([WeeklyTrainingSummary].self, from: Data(contentsOf: jsonURL)), sample)
}

func testFileWritingPropagatesErrors() {
let missingDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
XCTAssertThrowsError(try sample.writeCSV(to: missingDirectory.appendingPathComponent("weekly.csv")))
XCTAssertThrowsError(try sample.writeJSON(to: missingDirectory.appendingPathComponent("weekly.json")))
}

// MARK: - End to End

func testExportsParsedWorkoutsWithNormalisedStatisticsAndCalendarWeeks() throws {
let xml = """
<HealthData locale="en_AU">
<Workout workoutActivityType="Running" duration="30" durationUnit="min"
startDate="2026-04-20 00:30:00 +1000">
<WorkoutStatistics type="HKQuantityTypeIdentifierActiveEnergyBurned" sum="418.4" unit="kJ"/>
<WorkoutStatistics type="HKQuantityTypeIdentifierDistanceWalkingRunning" sum="5" unit="km"/>
<WorkoutStatistics type="HKQuantityTypeIdentifierHeartRate"
average="150" maximum="180" unit="count/min"/>
</Workout>
<Workout workoutActivityType="Yoga" startDate="2026-04-20 12:00:00 +1000"/>
</HealthData>
"""
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
calendar.firstWeekday = 2
let summaries = try HealthExportParser.parseExport(xml: xml).weeklyTrainingSummaries(calendar: calendar)

let rows = try summaries.csvString().split(separator: "\n")
XCTAssertEqual(rows.count, 3)
let run = rows[1].split(separator: ",", maxSplits: 7).map(String.init)
XCTAssertEqual(Array(run.prefix(3)), ["2026-04-13T00:00:00Z", "1", "1800"])
// Unit conversion can produce a floating point rounding remainder.
XCTAssertEqual(try XCTUnwrap(Double(run[3])), 100, accuracy: 0.000001)
XCTAssertEqual(Array(run.suffix(4)), ["5000", "150", "180", #""{""Running"":1}""#])
XCTAssertEqual(
rows[2],
#"2026-04-20T00:00:00Z,1,,,,,,"{""Yoga"":1}""#
)
XCTAssertEqual(try decoder.decode([WeeklyTrainingSummary].self, from: summaries.jsonData()), summaries)
}
}
32 changes: 32 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,3 +585,35 @@ A workout from the export, with one `WorkoutStatistics` per quantity type nested
A top-level record from the export. Quantity records carry a numeric `value`. Category records, such as sleep analysis, carry `categoryValue` as a string instead. Normalised helpers: `valueKilocalories`, `valueMetres`, `valueKilograms`, and `duration` from the start and end dates. Unknown record types are kept as `.other(type)`.

`HealthExport` holds `workouts` and `records` together, with `records(of:)` to filter records by kind.

### Weekly training summary export

`HealthExport.weeklyTrainingSummaries(calendar:)` groups workouts by their start
date into calendar weeks and returns summaries in date order. Workouts without
a start date are skipped. Pass an explicit calendar to control the time zone
and first day of the week.

`[WeeklyTrainingSummary]` supports `csvString()`, `jsonData()`, `writeCSV(to:)`
and `writeJSON(to:)`. All four methods throw on encoding or file errors. Exports
preserve array order and use ISO8601 dates. File writes atomically create or
replace the destination, whose parent directory must already exist.

CSV columns are `week_start`, `workout_count`, `duration_seconds`,
`active_energy_kcal`, `distance_metres`, `average_heart_rate`,
`maximum_heart_rate` and `activity_counts`. Heart rates are in beats per minute;
the average is the unweighted mean of per-workout averages. Missing metrics
are empty CSV fields and omitted JSON properties; measured zero stays zero.
Activity counts use a JSON object with sorted keys inside a quoted CSV field,
so activity names containing commas or quotes are preserved. Empty CSV exports
contain only the header and a newline; empty JSON exports contain an empty array.

```swift
let export = try HealthExportParser.parseExport(data: xmlData)
let weeks = export.weeklyTrainingSummaries(calendar: calendar)
try weeks.writeCSV(to: csvURL)
try weeks.writeJSON(to: jsonURL)

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let restored = try decoder.decode([WeeklyTrainingSummary].self, from: weeks.jsonData())
```
Loading