diff --git a/Packages/StrandImport/Sources/StrandImport/GarminExportParser.swift b/Packages/StrandImport/Sources/StrandImport/GarminExportParser.swift index c755c74ef..57613fe36 100644 --- a/Packages/StrandImport/Sources/StrandImport/GarminExportParser.swift +++ b/Packages/StrandImport/Sources/StrandImport/GarminExportParser.swift @@ -45,16 +45,29 @@ enum GarminExportParser { byDay[key] = row } } else { - // A daily-summary file: fold RHR / steps / stress / distance per calendarDate. + // A daily-summary file: fold RHR / steps / stress / distance / VO₂max per calendarDate. + // + // Only store a row once a field actually mapped. The GDPR export carries several + // dated files whose payload NOOP has no column for — `TrainingReadinessDTO_*` is the + // common one — and writing a row for each of those days produced hundreds of empty + // rows that displaced nothing but looked like a successful import. for d in records { guard let key = WearableJSON.str(d, "calendarDate") ?? WearableJSON.str(d, "calendar_date") else { continue } var row = day(key) - row.restingHr = WearableJSON.posInt(d, "restingHeartRate") ?? WearableJSON.posInt(d, "restingHeartRateInBeatsPerMinute") ?? row.restingHr - row.steps = WearableJSON.posInt(d, "totalSteps") ?? WearableJSON.posInt(d, "steps") ?? row.steps - row.distanceM = WearableJSON.posDbl(d, "totalDistanceMeters") ?? WearableJSON.posDbl(d, "totalDistanceInMeters") ?? row.distanceM - row.activeKcal = WearableJSON.posDbl(d, "activeKilocalories") ?? WearableJSON.posDbl(d, "activeCalories") ?? row.activeKcal - row.avgStress = WearableJSON.posInt(d, "averageStressLevel") ?? WearableJSON.posInt(d, "avgStressLevel") ?? row.avgStress - byDay[key] = row + var mapped = false + func set(_ path: WritableKeyPath, _ value: T?) { + guard let value else { return } + row[keyPath: path] = value + mapped = true + } + set(\.restingHr, WearableJSON.posInt(d, "restingHeartRate") ?? WearableJSON.posInt(d, "restingHeartRateInBeatsPerMinute")) + set(\.steps, WearableJSON.posInt(d, "totalSteps") ?? WearableJSON.posInt(d, "steps")) + set(\.distanceM, WearableJSON.posDbl(d, "totalDistanceMeters") ?? WearableJSON.posDbl(d, "totalDistanceInMeters")) + set(\.activeKcal, WearableJSON.posDbl(d, "activeKilocalories") ?? WearableJSON.posDbl(d, "activeCalories")) + set(\.avgStress, WearableJSON.posInt(d, "averageStressLevel") ?? WearableJSON.posInt(d, "avgStressLevel")) + // `MetricsMaxMetData_*` carries the VO₂max estimate that feeds Fitness Age. + set(\.vo2max, WearableJSON.posDbl(d, "vo2MaxValue")) + if mapped { byDay[key] = row } } } } @@ -125,8 +138,25 @@ enum GarminExportParser { // Heuristic: a value ≥ 1e11 is epoch-millis (≈ year 2001+); smaller is epoch-seconds. return Date(timeIntervalSince1970: ms >= 1e11 ? ms / 1000.0 : ms) } - if let s = WearableJSON.str(v, msKey), let d = WhoopTime.parse(s, offsetMinutes: 0) { return d } + if let s = WearableJSON.str(v, msKey), let d = WhoopTime.parse(normalizeISO(s), offsetMinutes: 0) { + return d + } if let secs = WearableJSON.dbl(v, secKey) { return Date(timeIntervalSince1970: secs) } return nil } + + /// Drop fractional seconds from a zone-less ISO timestamp. + /// + /// Garmin's GDPR export writes `2026-02-18T23:20:40.0` — fractional seconds with no zone + /// suffix — which `WhoopTime.parse` does not accept, so every night was silently discarded. + /// A timestamp that carries a zone (`…Z`, `…+02:00`) is left alone: those already parse, and + /// rewriting them would risk changing the instant. + static func normalizeISO(_ s: String) -> String { + guard let dot = s.firstIndex(of: ".") else { return s } + let tail = s[s.index(after: dot)...] + // Only strip when what follows the dot is purely digits — i.e. a fractional second and + // nothing else. `…40.0Z` or `…40.0+02:00` keep their zone and parse as they are. + guard !tail.isEmpty, tail.allSatisfy(\.isNumber) else { return s } + return String(s[s.startIndex.. Self.maxEntryBytes { continue } guard let data = try? Data(contentsOf: u) else { continue } - guard Self.isWellnessFile(name, data: data) else { continue } // Key on the path RELATIVE to the folder so brand detection can see "di_connect/..." etc. let rel = u.path.hasPrefix(base) ? String(u.path.dropFirst(base.count)).lowercased() : name - result[rel.hasPrefix("/") ? String(rel.dropFirst()) : rel] = data + let key = rel.hasPrefix("/") ? String(rel.dropFirst()) : rel + // Filter on the relative PATH, not the bare filename: several Garmin wellness files are + // identified only by the folder they sit in (`di_connect/…`), so matching the basename + // alone dropped whole categories — daily summaries and VO₂max among them. + guard Self.isWellnessFile(key, data: data) else { continue } + result[key] = data } return result } @@ -239,7 +243,9 @@ public struct WearableExportImporter { buffer.append(chunk) } } catch { continue } // corrupt / truncated / oversized → skip, never import partial - guard !buffer.isEmpty, Self.isWellnessFile(base, data: buffer) else { continue } + // Match on the full entry PATH for the same reason as the folder loader: a Garmin + // wellness file is often recognisable only by its folder. + guard !buffer.isEmpty, Self.isWellnessFile(path, data: buffer) else { continue } if result[path] == nil { result[path] = buffer } } return result diff --git a/Packages/StrandImport/Tests/StrandImportTests/WearableExportImporterTests.swift b/Packages/StrandImport/Tests/StrandImportTests/WearableExportImporterTests.swift index e08beb8c5..15a0840c8 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/WearableExportImporterTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/WearableExportImporterTests.swift @@ -389,6 +389,76 @@ final class WearableExportImporterTests: XCTestCase { XCTAssertEqual(d.avgStress, 31) } + func testGarminGDPRFractionalSecondTimestampsParse() throws { + // The real GDPR export writes zone-less ISO timestamps with a fractional second + // (`2026-06-01T00:00:00.0`). Before the fix these failed to parse, `sleepSession` returned + // nil for every record, and a whole export imported ZERO nights while still reporting + // success. Every night in the file must survive. + let sleepData = """ + [ { "calendarDate": "2026-06-01", + "sleepStartTimestampGMT": "2026-05-31T23:20:40.0", + "sleepEndTimestampGMT": "2026-06-01T05:32:40.0", + "deepSleepSeconds": 6060, "lightSleepSeconds": 12000, "remSleepSeconds": 3240, + "awakeSleepSeconds": 1020, "averageRespiration": 17.37 } ] + """ + let files = ["di_connect/di-connect-wellness/2026_sleepdata.json": bytes(sleepData)] + let r = WearableExportImporter.parse(brand: .garmin, files: files) + + XCTAssertEqual(r.sleeps.count, 1, "a fractional-second GDPR timestamp must not drop the night") + XCTAssertEqual(r.sleeps[0].totalSleepMin!, 355, accuracy: 1e-6) // (6060+12000+3240)/60 + XCTAssertEqual(try XCTUnwrap(r.days.first?.respRateBpm), 17.37, accuracy: 1e-6) + } + + func testNormalizeISOKeepsZonedTimestampsIntact() { + // Only a bare fractional second is stripped. Anything carrying a zone already parsed and + // must be handed through untouched, or the instant would shift. + XCTAssertEqual(GarminExportParser.normalizeISO("2026-06-01T05:32:40.0"), "2026-06-01T05:32:40") + XCTAssertEqual(GarminExportParser.normalizeISO("2026-06-01T05:32:40.000"), "2026-06-01T05:32:40") + XCTAssertEqual(GarminExportParser.normalizeISO("2026-06-01T05:32:40.5Z"), "2026-06-01T05:32:40.5Z") + XCTAssertEqual(GarminExportParser.normalizeISO("2026-06-01T05:32:40+02:00"), "2026-06-01T05:32:40+02:00") + XCTAssertEqual(GarminExportParser.normalizeISO("2026-06-01T05:32:40"), "2026-06-01T05:32:40") + } + + func testGarminDatedFileWithoutMappableFieldsWritesNoRow() { + // `TrainingReadinessDTO_*.json` is dated but carries nothing NOOP has a column for. It used + // to produce one EMPTY day row per date — hundreds of rows that made a broken import look + // like a successful one. + let readiness = """ + [ { "calendarDate": "2026-06-01", "level": "LOW", "score": 25, + "feedbackShort": "SLEEP_LOW", "sleepScoreFactorPercent": 82 } ] + """ + let files = ["di_connect/di-connect-metrics/trainingreadinessdto_2026.json": bytes(readiness)] + let r = WearableExportImporter.parse(brand: .garmin, files: files) + XCTAssertTrue(r.days.isEmpty, "a file with no mappable field must not create an empty day row") + } + + func testGarminVO2MaxFoldsOntoDayRow() { + // `MetricsMaxMetData_*.json` holds the VO₂max estimate that feeds Fitness Age. `vo2max` + // already existed on the row; nothing populated it from a Garmin export. + let maxMet = """ + [ { "calendarDate": "2026-06-01", "sport": "WALKING", "vo2MaxValue": 34.0, "maxMet": 9.7 } ] + """ + let files = ["di_connect/di-connect-metrics/metricsmaxmetdata_2026.json": bytes(maxMet)] + let r = WearableExportImporter.parse(brand: .garmin, files: files) + XCTAssertEqual(r.days.count, 1) + XCTAssertEqual(r.days[0].vo2max!, 34.0, accuracy: 1e-6) + } + + func testWellnessFilterMatchesOnPathNotOnlyBasename() { + // Several Garmin wellness files are identifiable only by the folder they sit in. Filtering + // on the bare filename dropped whole categories — daily summaries and VO₂max among them — + // while brand detection (which reads the full path) still said "garmin". + let blob = bytes("[]") + XCTAssertTrue(WearableExportImporter.isWellnessFile( + "di_connect/di-connect-aggregator/udsfile_2026-01-01_2026-04-10.json", data: blob), + "a daily-summary file must pass the filter via its folder") + XCTAssertTrue(WearableExportImporter.isWellnessFile( + "di_connect/di-connect-metrics/metricsmaxmetdata_2026.json", data: blob)) + XCTAssertFalse(WearableExportImporter.isWellnessFile( + "udsfile_2026-01-01_2026-04-10.json", data: blob), + "the bare filename alone still carries no wellness hint — that was the bug") + } + // MARK: - Safety / honesty func testJunkAndZeroValuesAreRejectedSafely() {