You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[health-connect] Imported records are day-keyed with the phone's current zone, not the offset the record carries #1002
HealthConnectImporter buckets every imported record into a local day with ZoneId.systemDefault(), the phone's zone at import time. Health Connect records carry startZoneOffset / endZoneOffset so a consumer does not have to guess, and the importer never reads them.
At c33b1a8, android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt:
214 val zone = ZoneId.systemDefault()
223 fun dayOf(instant: Instant): String = LocalDate.ofInstant(instant, zone).toString()
674 workout days, same zone
712/713/728 the live steps top-up, its own systemDefault()
grep -c "startZoneOffset\|endZoneOffset" over the file returns 0.
Why it matters
WINDOW_YEARS = 10, so the first import covers ten years of history and keys all of it with whatever offset the phone is in on the day of that import. A record made under a different offset gets a day boundary computed with the wrong one, and a record near local midnight tips onto the neighbouring day. For anyone observing DST that is one side of each transition, twice a year, and travel does the same for the length of the trip.
The day key is what daily aggregates and cross-source comparison join on, so a mis-keyed night surfaces as a day holding two partial readings, or as a gap beside a doubled day, rather than as an obviously wrong timestamp.
Found by reading the importer, not from a user report, so I have not measured how many days move in practice.
Suggested direction
Prefer the record's own offset where the record type carries one, and fall back to systemDefault() only when it is absent. Doing it per record rather than per import means the ten year backfill keys each record the way it was recorded.
No PR opened. Happy to send one, with a test that imports a record whose offset differs from the phone's.
Confirmed, and the line references are exact at current main: 214, 223, 674, 712/713/728, and grep -c "startZoneOffset\|endZoneOffset" is still 0.
Two things to settle before a PR, because the fix is bigger than the one file.
It isn't Android-only. The iOS twin does the same thing — HealthKitBridge.swift uses Calendar.current at 311/369/597/929 and TimeZone.current at 688/1189. So a Kotlin-only change breaks the parity contract rather than fixing a one-sided bug. iOS is also the harder half: HealthKit has no per-record offset except the optional HKMetadataKeyTimeZone, and HKStatisticsCollectionQuery buckets by the anchor calendar, so matching Android means moving to HKSampleQuery plus a fallback that will fire often.
There are already two day-key conventions in the tree, and this is the seam. Keyed by the record's own offset: WhoopDayKeying (whose doc comment says "in the cycle's own UTC offset"), AppleHealthAggregator.localDay(_:tzOffsetMin:), AppleHealthImporter.localDay(epochSecs, offsetMin), XiaomiBandImporter.dayKey(time, zoneOffset). Keyed by the device's current zone: Repository.dayFormatter (no timeZone set), HealthKitBridge.dayFormatter, and this importer.
Every export importer is in the first group; every live path is in the second. Your change moves Health Connect from the second to the first, which I agree is the more defensible convention — but its rows then disagree with the strap-computed rows they merge with, which is the tension already written down at HealthKitBridge.swift:1175. The two conventions only diverge under DST and travel, which is exactly the case being reported, so picking one is the actual decision here.
Two couplings a PR has to handle:
The live steps top-up at 712–728 deliberately bucket-matches import's dayOf — the comment at 728 says so. Change one and both have to move, or today's steps land in a different bucket from the imported history.
Worth noting the re-import case generally: because the key is computed at import time, importing the same ten years twice in different DST phases produces different keys for the same records. That's the doubled-day-beside-a-gap symptom, and it needs no travel.
Yes to a PR, please — Android importer first, with the test you described. Leave the iOS side out of it; I'd rather decide the convention on a concrete diff than up front. Say in the PR what happens at the seam for a user whose phone zone differs from a record's, so the trade is visible.
One correction to my last comment, where I repeated the DST framing without checking it. DST was never affected. ZoneId.systemDefault() returns a region id, not a fixed offset, and LocalDate.ofInstant resolves a region with the rules in force at that instant — so a January record is already keyed with January's offset when the import runs in July:
winter instant in London : 2026-01-15 (offset Z)
summer instant in London : 2026-07-16 (offset +01:00)
same winter instant, fixed +01:00 pinned: 2026-01-16 <- what would be wrong
The mechanism you identified is real, and the rest of the report stands — it just needs the record's zone to differ from the phone's current one, so it is travel and relocation rather than twice a year for everyone. A record written at 01:30 in Tokyo resolves to the previous day on a phone set to Europe/London. That third line is now a regression test, because reaching for a fixed current offset is the obvious wrong fix and it would break what was working.
The PR uses the record's offset where it has one and keeps the phone's zone as the fallback, so nothing moves for records that carry no offset. All 17 call sites are threaded, including the workout post-pass, which had to carry the key forward rather than re-derive it from WorkoutRow's epoch seconds — that path would have fallen back to the phone's zone and undone the fix for exactly the travelled sessions.
iOS is left out deliberately, with the reasoning in the PR: HealthKit has no per-record offset beyond the optional HKMetadataKeyTimeZone, and HKStatisticsCollectionQuery buckets by the anchor calendar, so it is a different change rather than the same one twice.
Worth a look if you have a store that has actually travelled — the offsets come from the platform and I could only test the keying, not the record shapes.
Activity
ryanbr commentedon Jul 31, 2026
Confirmed, and the line references are exact at current
main: 214, 223, 674, 712/713/728, andgrep -c "startZoneOffset\|endZoneOffset"is still 0.Two things to settle before a PR, because the fix is bigger than the one file.
It isn't Android-only. The iOS twin does the same thing —
HealthKitBridge.swiftusesCalendar.currentat 311/369/597/929 andTimeZone.currentat 688/1189. So a Kotlin-only change breaks the parity contract rather than fixing a one-sided bug. iOS is also the harder half: HealthKit has no per-record offset except the optionalHKMetadataKeyTimeZone, andHKStatisticsCollectionQuerybuckets by the anchor calendar, so matching Android means moving toHKSampleQueryplus a fallback that will fire often.There are already two day-key conventions in the tree, and this is the seam. Keyed by the record's own offset:
WhoopDayKeying(whose doc comment says "in the cycle's own UTC offset"),AppleHealthAggregator.localDay(_:tzOffsetMin:),AppleHealthImporter.localDay(epochSecs, offsetMin),XiaomiBandImporter.dayKey(time, zoneOffset). Keyed by the device's current zone:Repository.dayFormatter(notimeZoneset),HealthKitBridge.dayFormatter, and this importer.Every export importer is in the first group; every live path is in the second. Your change moves Health Connect from the second to the first, which I agree is the more defensible convention — but its rows then disagree with the strap-computed rows they merge with, which is the tension already written down at
HealthKitBridge.swift:1175. The two conventions only diverge under DST and travel, which is exactly the case being reported, so picking one is the actual decision here.Two couplings a PR has to handle:
import'sdayOf— the comment at 728 says so. Change one and both have to move, or today's steps land in a different bucket from the imported history.hydrationMloff the samedayOfat 506, and that write replaces per-day rows. A re-import under a different offset can move water onto a neighbouring day, not just add a duplicate.Worth noting the re-import case generally: because the key is computed at import time, importing the same ten years twice in different DST phases produces different keys for the same records. That's the doubled-day-beside-a-gap symptom, and it needs no travel.
Yes to a PR, please — Android importer first, with the test you described. Leave the iOS side out of it; I'd rather decide the convention on a concrete diff than up front. Say in the PR what happens at the seam for a user whose phone zone differs from a record's, so the trade is visible.
ryanbr commentedon Jul 31, 2026
Built it: #1003.
One correction to my last comment, where I repeated the DST framing without checking it. DST was never affected.
ZoneId.systemDefault()returns a region id, not a fixed offset, andLocalDate.ofInstantresolves a region with the rules in force at that instant — so a January record is already keyed with January's offset when the import runs in July:The mechanism you identified is real, and the rest of the report stands — it just needs the record's zone to differ from the phone's current one, so it is travel and relocation rather than twice a year for everyone. A record written at 01:30 in Tokyo resolves to the previous day on a phone set to Europe/London. That third line is now a regression test, because reaching for a fixed current offset is the obvious wrong fix and it would break what was working.
The PR uses the record's offset where it has one and keeps the phone's zone as the fallback, so nothing moves for records that carry no offset. All 17 call sites are threaded, including the workout post-pass, which had to carry the key forward rather than re-derive it from
WorkoutRow's epoch seconds — that path would have fallen back to the phone's zone and undone the fix for exactly the travelled sessions.iOS is left out deliberately, with the reasoning in the PR: HealthKit has no per-record offset beyond the optional
HKMetadataKeyTimeZone, andHKStatisticsCollectionQuerybuckets by the anchor calendar, so it is a different change rather than the same one twice.Worth a look if you have a store that has actually travelled — the offsets come from the platform and I could only test the keying, not the record shapes.
Health Connect: key each record by the offset it was recorded in (#1002…
Health Connect: key each record by the offset it was recorded in (rya…
android(health-connect): import blood pressure into the Lab Book