Skip to content

Fix Garmin GDPR wellness import discarding every night - #6

Open
endvater wants to merge 1 commit into
ParthJadhav:mainfrom
endvater:fix/garmin-gdpr-wellness-import
Open

endvater wants to merge 1 commit into
ParthJadhav:mainfrom
endvater:fix/garmin-gdpr-wellness-import

Conversation

@endvater

@endvater endvater commented Sep 6, 2026

Copy link
Copy Markdown

A Garmin Export Your Data archive currently imports zero sleep sessions and a pile of empty day rows, while still reporting success. I hit this with a real 6-year export and traced it to three defects that compound.

1. Zone-less fractional-second timestamps fail to parse

Garmin's GDPR export writes 2026-02-18T23:20:40.0. WhoopTime.parse accepts …40, …40Z and …40+02:00, but not a bare fractional second — so instant() returned nil, the guard in sleepSession(_:) fired, and every night was skipped.

Garmin always writes .0, so this is not an edge case: it affects every Garmin export.

timestamp before after
2026-02-18T23:20:40.0 ✗ dropped
2026-02-18T23:20:40.000 ✗ dropped
2026-02-18T23:20:40Z
2026-02-18T23:20:40

normalizeISO strips the fraction only when what follows the dot is purely digits, so anything carrying a zone is handed through untouched and the instant cannot shift.

2. Dated files with no mappable field wrote empty day rows

TrainingReadinessDTO_*.json passes the name filter on "readiness" and carries calendarDate, but none of the fields the parser reads. It produced one blank row per date — enough rows to make a broken import look like a working one. The day-summary branch now only stores a row once a field actually mapped.

3. The file filter matched the basename, brand detection matched the path

isWellnessFile received lastPathComponent, so files identifiable only by their folder never reached the parser:

  • UDSFile_*.json — steps, resting HR, stress, distance
  • MetricsMaxMetData_*.json — VO₂max

…while detectBrand, which reads the full path, still said garmin. Both loaders now filter on the relative path. di_connect was already in the hint list; it simply never matched.

WearableDailyRow.vo2max already existed and nothing populated it from a Garmin export. It now folds in with the other daily metrics.

Verified against a real export

163 MB archive, 221 wellness JSONs, February 2020 – September 2026:

before after
day rows 463 (all empty) 2312
sleep sessions 0 2284
with resting HR 0 2215
with steps 0 2218
with VO₂max 0 338
range 2025-05-31 .. 2026-09-05 2015-01-24 .. 2026-09-05

Path-based filtering pulls 29 MB of JSON/CSV from that archive — the FIT files are still excluded by the extension check, so the byte cost is modest.

Tests

Five added to WearableExportImporterTests, one per defect plus a guard that zoned timestamps survive normalisation unchanged. swift test in Packages/StrandImport: 171 passed, 0 failures.

Scope is deliberately limited to the wellness lane — no behaviour change for Oura, Fitbit, or the FIT activity importer.

A Garmin "Export Your Data" archive imported zero sleep sessions and a pile
of empty day rows, while still reporting success. Three defects compounded:

1. Zone-less fractional-second timestamps failed to parse.
   The GDPR export writes `2026-02-18T23:20:40.0`. `WhoopTime.parse` accepts
   `…40`, `…40Z` and `…40+02:00` but not a bare fractional second, so
   `instant()` returned nil, the `guard` in `sleepSession` fired, and EVERY
   night was skipped. Garmin always writes `.0`, so this affected every
   Garmin export, not an edge case.

2. Dated files with no mappable field wrote empty day rows.
   `TrainingReadinessDTO_*.json` passes the name filter on "readiness" and
   carries `calendarDate`, but none of the fields the parser reads. It
   produced one blank row per date — enough rows to make a broken import
   look like a working one.

3. The file filter matched the basename, brand detection matched the path.
   `isWellnessFile` received `lastPathComponent`, so files identified only
   by their folder never reached the parser: `UDSFile_*` (steps, resting HR,
   stress, distance) and `MetricsMaxMetData_*` (VO₂max) were dropped, while
   `detectBrand` — which reads the full path — still said "garmin".

The VO₂max field already existed on `WearableDailyRow`; nothing populated it
from a Garmin export. It now folds in with the other daily metrics.

Verified against a real 6-year export (163 MB, 221 wellness JSONs):

  before   463 day rows, all empty          0 sleep sessions
  after  2312 day rows, 2215 with RHR    2284 sleep sessions
         2218 with steps, 338 with VO₂max
         2015-01-24 .. 2026-09-05

Path-based filtering loads 29 MB of JSON/CSV from that archive; the FIT
files are still excluded by the extension check.

Five tests added, covering each defect and pinning that zoned timestamps
are handed through untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant