Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.parseaccepts…40,…40Zand…40+02:00, but not a bare fractional second — soinstant()returned nil, theguardinsleepSession(_:)fired, and every night was skipped.Garmin always writes
.0, so this is not an edge case: it affects every Garmin export.2026-02-18T23:20:40.02026-02-18T23:20:40.0002026-02-18T23:20:40Z2026-02-18T23:20:40normalizeISOstrips 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_*.jsonpasses the name filter on"readiness"and carriescalendarDate, 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
isWellnessFilereceivedlastPathComponent, so files identifiable only by their folder never reached the parser:UDSFile_*.json— steps, resting HR, stress, distanceMetricsMaxMetData_*.json— VO₂max…while
detectBrand, which reads the full path, still saidgarmin. Both loaders now filter on the relative path.di_connectwas already in the hint list; it simply never matched.WearableDailyRow.vo2maxalready 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:
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 testinPackages/StrandImport: 171 passed, 0 failures.Scope is deliberately limited to the wellness lane — no behaviour change for Oura, Fitbit, or the FIT activity importer.