Skip to content

[health-connect] Imported records are day-keyed with the phone's current zone, not the offset the record carries #1002

Closed

Description

@tanarchytan

What

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.

Activity

  1. ryanbr commented on Jul 31, 2026

    @ryanbr
    Owner

    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.
    • Hydration (Feature request: Apple Health integration for hydration tracking #949) keys hydrationMl off the same dayOf at 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.

  2. ryanbr commented on Jul 31, 2026

    @ryanbr
    Owner

    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, 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions