Skip to content

feat(apple): hourly step import (v26) — per-hour iPhone steps with 90-day backfill#369

Open
vishk23 wants to merge 4 commits into
ryanbr:mainfrom
vishk23:apple-hourly-steps
Open

feat(apple): hourly step import (v26) — per-hour iPhone steps with 90-day backfill#369
vishk23 wants to merge 4 commits into
ryanbr:mainfrom
vishk23:apple-hourly-steps

Conversation

@vishk23

@vishk23 vishk23 commented Jul 13, 2026

Copy link
Copy Markdown

What. The Apple Health import currently flattens iPhone steps to one daily total. This adds an hourly step import alongside it: migration v26-apple-step-hour (additive table appleStepHour(deviceId, ts hour-start, steps)), an HKStatisticsCollectionQuery at hour interval over the same sync window (same anchor, auth checks, and error tolerance as the existing daily collect()), and a one-time 90-day historical backfill (UserDefaults-gated, set only after a successful collection — a HealthKit query error throws into the existing catch and retries next sync, so a transient failure can't permanently skip the backfill).

Why. Intra-day phone-vs-strap comparison answers questions daily totals can't: when was the phone actually recording (dead battery windows show as missing hours), how much of a wrist-count gap is arm-motion inflation vs. the phone simply not being carried, and hour-level activity timelines generally. Verified on a real device: a 90-day backfill landed, and an hour-by-hour overlay against strap step ticks cleanly isolated a mid-hike dead-phone window and quantified wrist-vs-pocket divergence per hour.

Scope. Plumbing + tests only — no UI reads the table yet (same precedent as the WHOOP 5 event-log capture, #346). Store API (upsertAppleStepHours/appleStepHours) mirrors the OuraRawStore extension style; appleStepHour is listed in deviceScopedTables (wiped with the device's data). Android: Kotlin schema twin included + gradle-tested. The Room migration (v18→v19) + AppleStepHour @entity mirror the appleStepHour GRDB table (columns deviceId, ts, steps; PK (deviceId, ts); column order == entity field order) so the .noopbak schema stays byte-identical, pinned by AppleStepHourMigrationTest. No import code is ported: the hourly-step collection is the iOS-only live-HealthKit path (HealthKit has no Android analogue) and the file-based importer both platforms share is untouched. ./gradlew :app:testFullDebugUnitTest green.

Tests/verification. WhoopStore package suite green including 3 new store tests (idempotent upsert by natural key, range read) and the device-scoped-tables invariant; both app targets build. The backfill-flag error path is the second commit — a review caught that a swallowed HealthKit error could set the done-flag with zero rows persisted; collectHourlySteps now throws on a real query error so the flag only sets after success.

Note: the migration uses ifNotExists so existing sideload databases that already carry this table converge cleanly.

…-day backfill

The Apple Health importer flattened steps to one daily total, so a phone that
died mid-hike just read as a low/zero day instead of showing which hours had
no data. HealthKit retains hourly statistics historically, so this adds a
per-hour store (appleStepHour, PK deviceId+ts) and wires an hourly
HKStatisticsCollectionQuery alongside the existing daily collector, with a
one-time 90-day backfill on first run so past days answer retroactively too.
A transient HealthKit query error now throws instead of silently succeeding,
so the backfill flag is only set after a real collection completes — an HK
error retries on the next sync instead of permanently losing the 90-day
window.

Migration registers as v26-apple-step-hour (the next open slot on this repo's
migrator) with options: [.ifNotExists] on the table create — forks/sideloads
that already carry this table under a different migration identifier
converge cleanly instead of failing the migrator.
@ryanbr

ryanbr commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Really clean instrument-first PR — additive ifNotExists migration, idempotent (deviceId, ts) store mirroring OuraRawStore, device-scoped-delete wired, tests, and no UI consumer yet (the #346 precedent). The v26 numbering is correctly next after v25-oura-raw, and the iOS-only scope is the right call (this extends the live-HealthKit path; Android's Health Connect importer is separate). The 2nd commit's throw-on-error is the right fix for the transient-error lockout.

One robustness nit on the backfill flag — it closes the error path but not the arguably-more-common empty path:

This bridge treats a successful auth request as .authorized and, per its own doc (line ~116), "let[s] queries return empty if the user declined." HealthKit returns empty, not an error, when step read-access is denied. But the flag is set unconditionally on empty:

if !hourlySteps.isEmpty { try await store.upsertAppleStepHours(hourlySteps, ) }
if !hourlyStepsBackfilled { UserDefaults.standard.set(true, forKey: hourlyStepsBackfilledKey) }  // sets even when empty

So: first sync with step-read declined → empty → flag set → user later grants step access → the 90-day hourly backfill is permanently skipped (forward data only). Users grant Health scopes incrementally, so this deny-then-grant path is easy to hit — and it silently loses exactly the historical hours the backfill exists to capture.

Suggested 1-line guard — only mark backfilled once real hourly data actually lands:

if !hourlySteps.isEmpty {
    try await store.upsertAppleStepHours(hourlySteps, deviceId: appleDeviceId)
    if !hourlyStepsBackfilled { UserDefaults.standard.set(true, forKey: HealthKitBridge.hourlyStepsBackfilledKey) }
}

Trade-off: a genuinely step-less 90-day HealthKit (strap-only user who never carries a phone) would re-scan each sync until some step exists — cheap, bounded, and self-healing, and far better than losing the backfill for deny-then-grant. Since the flag lives in app-target HealthKit code (not unit-testable, rests on the on-device run), erring conservative is worth it.

Not blocking given it's instrumentation with no consumer yet — but worth folding in now or as a fast follow, since the whole value of the one-time widen is the historical hours.

HealthKit returns empty (not an error) when step read-access is denied,
so setting the flag unconditionally after a no-throw collection burned
the one-time 90-day backfill in a deny-then-grant sequence. Gate the
flag-set inside the non-empty branch: a later grant still gets the
historical widen, and a genuinely step-less user just re-scans each
sync — cheap, bounded, self-healing.
@vishk23

vishk23 commented Jul 13, 2026

Copy link
Copy Markdown
Author

Folded in (ff42c13). The flag-set now lives inside the !hourlySteps.isEmpty branch, exactly as suggested — the backfill is only marked done once hourly rows actually land, so a deny-then-grant sequence keeps the 90-day widen, and a genuinely step-less user just re-scans each sync (cheap, bounded, self-healing). Added a comment at the site explaining why non-empty (not just no-throw) is the gate, since HealthKit signals denied read-access with empty results rather than an error.

Verified compile-only (this is app-target code CI doesn't build): xcodegen generate, then both xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build and xcodebuild -scheme NOOPiOS -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO build — BUILD SUCCEEDED on both. The deny-then-grant HealthKit behavior itself rests on an on-device run, same as the rest of this path; I'll fold that into the next device verification pass.

vishk23 added 2 commits July 14, 2026 13:34
Adds the Room migration (v18 -> v19) + @entity for the appleStepHour table,
the Android twin of the Swift WhoopStore v26-apple-step-hour GRDB migration.
Column order (deviceId, ts, steps) and PK (deviceId, ts) match the GRDB schema
so the .noopbak schema stays byte-identical, pinned by AppleStepHourMigrationTest.

SCHEMA-ONLY: the hourly-step import is Apple-Health-sourced (iOS-only; HealthKit
has no Android analogue), so no import code is ported — Android carries the table
for backup parity but no importer writes to it.
…ream

Upstream took every slot this branch had claimed, on both platforms. Four
conflicts, all schema-numbering, resolved by moving this PR's migration to the
end of each chain — the table itself, its SQL, and its tests are unchanged.

- GRDB: `v26-apple-step-hour` → **`v31-apple-step-hour`**. Upstream now holds
  v26 (efficiency-heal), v27 (ppg-waveform), v28 (raw-imu),
  v29 (score-input-provenance), v30 (rr-ord).
- Room: `MIGRATION_18_19` → **`MIGRATION_24_25`**, database `version` 19 → 25.
  Upstream is at version 24 / `MIGRATION_23_24`; `MIGRATION_24_25` is appended
  to `addMigrations(...)` and `AppleStepHour::class` re-added to the entity list
  alongside upstream's `PpgWaveformSampleEntity` / `RawImuSampleEntity`.
- `DeviceRegistryStore.deviceScopedTables` and `Entities.kt` were additive
  collisions — upstream's new tables and `appleStepHour` all kept.
- `AppleStepHourMigrationTest` version-pair assertions updated to 24→25.

NOTE: PR #475 currently also claims GRDB v31 (`v31-daily-avg-sdnn`). GRDB keys
migrations by name so both would apply correctly in either order, but whichever
of the two lands second should bump to v32 to keep the numbering honest.

Verified: WhoopStore 299/299 (incl. the 3 AppleStepHourStore tests); Android
`./gradlew testFullDebugUnitTest` 3064 tests, only the pre-existing
`SyncChipStateTest.lastSyncedAt_takesPriorityOverHistorySync` failure (it fails
on clean main too).
@vishk23

vishk23 commented Jul 27, 2026

Copy link
Copy Markdown
Author

Rebuilt on current main (the branch was 321 commits behind and DIRTY). Upstream had taken every schema slot this PR claimed, on both platforms, so all four conflicts were numbering — the table, its SQL and its tests are otherwise untouched:

  • GRDB v26-apple-step-hourv31-apple-step-hour (upstream now holds v26 efficiency-heal, v27 ppg-waveform, v28 raw-imu, v29 score-input-provenance, v30 rr-ord).
  • Room MIGRATION_18_19MIGRATION_24_25, database version 19 → 25 (upstream is at 24 / MIGRATION_23_24). MIGRATION_24_25 appended to addMigrations(...), AppleStepHour::class re-added alongside upstream's PpgWaveformSampleEntity / RawImuSampleEntity, and the AppleStepHourMigrationTest version-pair assertions updated to 24→25.
  • DeviceRegistryStore.deviceScopedTables and Entities.kt were additive collisions; upstream's new tables and appleStepHour all kept, so the device-data wipe still clears it.

Heads-up for whoever lands these: #475 currently also claims GRDB v31 (v31-daily-avg-sdnn). GRDB keys migrations by name, so both apply correctly in either order, but whichever of the two merges second should bump to v32 to keep the numbering honest. Happy to be the one that moves.

The review nit from July — only setting the backfill flag once hourly rows actually land, so a deny-then-grant HealthKit sequence keeps the 90-day widen — is in the branch already (ff42c13) and survived the merge unchanged.

Verified: WhoopStore 299/299, including the three AppleStepHourStore tests and the renumbered migration. Android ./gradlew testFullDebugUnitTest 3064 tests, only the pre-existing SyncChipStateTest.lastSyncedAt_takesPriorityOverHistorySync failure (fails on clean main too). StrandiOS/Health/HealthKitBridge.swift is app-target Swift no CI job compiles, so I built it: xcodegen generate + xcodebuild -scheme NOOPiOS -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NOBUILD SUCCEEDED.

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.

2 participants