From 19d9db4d1cd46b7d5875666106f2e27e0dd51f2d Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:29:39 +0700 Subject: [PATCH] feat(health): export weekly training summaries as CSV and JSON Add weekly export files with explicit units, missing-value handling and safely quoted activity counts. Verify the XML-to-weekly-export flow and document the package API. Co-authored-by: OpenAI Codex --- .../Models/WeeklyTrainingSummaryExport.swift | 67 +++++++ .../WeeklyTrainingSummaryExportTests.swift | 169 ++++++++++++++++++ docs/api.md | 32 ++++ 3 files changed, 268 insertions(+) create mode 100644 Sources/BetterFit/Models/WeeklyTrainingSummaryExport.swift create mode 100644 Tests/BetterFitTests/WeeklyTrainingSummaryExportTests.swift diff --git a/Sources/BetterFit/Models/WeeklyTrainingSummaryExport.swift b/Sources/BetterFit/Models/WeeklyTrainingSummaryExport.swift new file mode 100644 index 0000000..0ca63ca --- /dev/null +++ b/Sources/BetterFit/Models/WeeklyTrainingSummaryExport.swift @@ -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) + } +} diff --git a/Tests/BetterFitTests/WeeklyTrainingSummaryExportTests.swift b/Tests/BetterFitTests/WeeklyTrainingSummaryExportTests.swift new file mode 100644 index 0000000..2b5c8d1 --- /dev/null +++ b/Tests/BetterFitTests/WeeklyTrainingSummaryExportTests.swift @@ -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 = """ + + + + + + + + + """ + 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) + } +} diff --git a/docs/api.md b/docs/api.md index a717ae1..ba3a89f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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()) +```