Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(_ path: WritableKeyPath<WearableDailyRow, T?>, _ 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 }
}
}
}
Expand Down Expand Up @@ -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..<dot])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,14 @@ public struct WearableExportImporter {
let size = (try? u.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
if size > 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
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down