Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/AppleStepHourStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation
import GRDB

// MARK: - v27 store: hourly Apple Health step counts
// Mirrors OuraRawStore: an idempotent ON CONFLICT upsert keyed by the natural key (deviceId, ts), and
// a range read — all GRDB work via syncWrite/syncRead. `appleDaily.steps` already answers "how many
// steps that day"; this table answers "which HOURS were recorded", so the UI can show retroactively
// when the iPhone was off/dead/left behind (e.g. mid-hike) instead of a single flattened daily total.

extension WhoopStore {

/// Upsert hourly Apple Health step counts. Idempotent by (deviceId, ts): re-importing the same
/// hour overwrites its count rather than duplicating. Returns rows changed.
@discardableResult
public func upsertAppleStepHours(_ rows: [(ts: Int, steps: Int)], deviceId: String) async throws -> Int {
try syncWrite { db in
var n = 0
for r in rows {
try db.execute(sql: """
INSERT INTO appleStepHour (deviceId, ts, steps)
VALUES (?, ?, ?)
ON CONFLICT(deviceId, ts) DO UPDATE SET
steps = excluded.steps
""", arguments: [deviceId, r.ts, r.steps])
n += db.changesCount
}
return n
}
}

/// Hourly step counts for a device over `[fromTs, toTs]` inclusive, oldest hour first.
public func appleStepHours(deviceId: String, fromTs: Int, toTs: Int) async throws -> [(ts: Int, steps: Int)] {
try syncRead { db in
try Row.fetchAll(db, sql: """
SELECT ts, steps FROM appleStepHour
WHERE deviceId = ? AND ts >= ? AND ts <= ?
ORDER BY ts ASC
""", arguments: [deviceId, fromTs, toTs])
.map { (ts: $0["ts"], steps: $0["steps"]) }
}
}
}
29 changes: 29 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,35 @@ extension WhoopStore {
t.primaryKey(["deviceId", "ts"])
}
}

// v32-apple-step-hour: hourly Apple Health step counts. The daily `collect(.stepCount, …)` path
// in HealthKitBridge flattens a whole day to one `appleDaily.steps` total, so a dead/absent phone
// for part of a day (e.g. the phone died mid-hike) is invisible — steps just read low for the
// WHOLE day instead of showing exactly which hours had no recording. HealthKit retains hourly
// statistics historically, so this table lets a one-time backfill answer PAST days
// retroactively, not just from the day this migration ships. `ts` is the hour-BUCKET START
// (unix seconds), aligned to local-clock hour boundaries by the `HKStatisticsCollectionQuery`
// anchored at local midnight (see HealthKitBridge); `steps` is the cumulative sum within that
// hour. PK (deviceId, ts) mirrors every other per-sample table (hrSample, stepSample, …) and
// makes the hourly upsert idempotent. Additive only — a NEW table, no existing row touched, old
// readers unaffected.
//
// Numbered v32, not the v31 this branch previously claimed: upstream took v31
// (`v31-deep-capture-channels`) after this branch was opened. #475 is the other v31 claimant and
// has moved to `v32-daily-avg-sdnn`; the strict v1...vN baseline check means "next" is a SINGLE
// slot, so the two cannot both be green at v32 and v33 at once. Per the offer in this thread,
// THIS is the PR that moves again: whichever of the two lands first, this one is renumbered to
// v33 (and Room to 26 -> 27) rather than asking the other to.
migrator.registerMigration("v32-apple-step-hour") { db in
// ifNotExists: forks/sideloads that already carry this table under a different migration
// identifier converge cleanly instead of failing the migrator.
try db.create(table: "appleStepHour", options: [.ifNotExists]) { t in
t.column("deviceId", .text).notNull() // "apple-health"
t.column("ts", .integer).notNull() // hour-start unix seconds (local-hour aligned by HK)
t.column("steps", .integer).notNull()
t.primaryKey(["deviceId", "ts"])
}
}
return migrator
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ public struct DeviceRegistryStore: Sendable {
// v31-deep-capture-channels: the banked 5/MG v18 auxiliary fields are deviceId-keyed per-second
// rows like every stream above, so a "delete all of this device's data" must clear them too.
"v18AuxSample",
// v32-apple-step-hour: per-hour Apple Health step counts are deviceId-keyed (apple-health) and
// are real per-device recordings like appleDaily/stepSample, not tombstone-class bookkeeping —
// a device-data wipe / "Remove Apple Health data" must clear them too.
"appleStepHour",
]

/// Permanently delete every recorded sample/derived row belonging to one device, across all
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import XCTest
import GRDB
@testable import WhoopStore

final class AppleStepHourStoreTests: XCTestCase {
func testUpsertIsIdempotentByNaturalKey() async throws {
let store = try await WhoopStore.inMemory()
let n1 = try await store.upsertAppleStepHours([(ts: 1_000, steps: 120)], deviceId: "apple-health")
XCTAssertEqual(n1, 1)

// Re-import the same hour with a revised count → overwrite in place, still one row.
let n2 = try await store.upsertAppleStepHours([(ts: 1_000, steps: 250)], deviceId: "apple-health")
XCTAssertEqual(n2, 1)

let rows = try await store.appleStepHours(deviceId: "apple-health", fromTs: 0, toTs: 10_000)
XCTAssertEqual(rows.count, 1)
XCTAssertEqual(rows.first?.ts, 1_000)
XCTAssertEqual(rows.first?.steps, 250)
}

func testRangeReadReturnsOldestFirstWithinBoundsInclusive() async throws {
let store = try await WhoopStore.inMemory()
_ = try await store.upsertAppleStepHours([
(ts: 3_600, steps: 100), // hour 1
(ts: 7_200, steps: 200), // hour 2
(ts: 10_800, steps: 300), // hour 3
], deviceId: "apple-health")

// Inclusive bounds: both edges of the range are included.
let rows = try await store.appleStepHours(deviceId: "apple-health", fromTs: 3_600, toTs: 10_800)
XCTAssertEqual(rows.map(\.ts), [3_600, 7_200, 10_800])
XCTAssertEqual(rows.map(\.steps), [100, 200, 300])

// A narrower window excludes rows outside it.
let narrow = try await store.appleStepHours(deviceId: "apple-health", fromTs: 3_601, toTs: 10_799)
XCTAssertEqual(narrow.map(\.ts), [7_200])

// A different device sees no rows.
let other = try await store.appleStepHours(deviceId: "my-whoop", fromTs: 0, toTs: 100_000)
XCTAssertTrue(other.isEmpty)
}

func testUpsertPartitionsByDevice() async throws {
let store = try await WhoopStore.inMemory()
_ = try await store.upsertAppleStepHours([(ts: 1_000, steps: 50)], deviceId: "apple-health")
_ = try await store.upsertAppleStepHours([(ts: 1_000, steps: 999)], deviceId: "other-device")

let mine = try await store.appleStepHours(deviceId: "apple-health", fromTs: 0, toTs: 10_000)
XCTAssertEqual(mine.count, 1)
XCTAssertEqual(mine.first?.steps, 50)

let others = try await store.appleStepHours(deviceId: "other-device", fromTs: 0, toTs: 10_000)
XCTAssertEqual(others.count, 1)
XCTAssertEqual(others.first?.steps, 999)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.",
"roomVersion": 25,
"roomVersion": 26,
"grdbMigrations": [
"v1",
"v2",
Expand Down Expand Up @@ -32,7 +32,8 @@
"v28-raw-imu",
"v29-score-input-provenance",
"v30-rr-ord",
"v31-deep-capture-channels"
"v31-deep-capture-channels",
"v32-apple-step-hour"
],
"divergenceReasons": {
"android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.",
Expand Down Expand Up @@ -115,6 +116,34 @@
],
"indices": []
},
"appleStepHour": {
"platform": "both",
"columns": [
{
"name": "deviceId",
"affinity": "TEXT",
"notNull": true,
"default": null
},
{
"name": "ts",
"affinity": "INTEGER",
"notNull": true,
"default": null
},
{
"name": "steps",
"affinity": "INTEGER",
"notNull": true,
"default": null
}
],
"primaryKey": [
"deviceId",
"ts"
],
"indices": []
},
"battery": {
"platform": "both",
"columns": [
Expand Down
67 changes: 67 additions & 0 deletions StrandiOS/Health/HealthKitBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,11 @@ final class HealthKitBridge: ObservableObject {

// MARK: - Read → store

/// UserDefaults key gating the one-time 90-day hourly-step backfill (see the hourly collection
/// below). Set once the first hourly-step HealthKit query completes; every later `sync()` only
/// walks the same window the daily collectors already use.
private static let hourlyStepsBackfilledKey = "applehealth.hourlySteps.backfilled"

/// Pull the last `days` of Apple Health into the on-device store under the `apple-health` source,
/// then write NOOP's own computed metrics back into Health. Safe to call repeatedly (idempotent
/// upserts keyed by day).
Expand Down Expand Up @@ -370,6 +375,16 @@ final class HealthKitBridge: ObservableObject {
byDay[day] = a
}

// Hourly step counts: `appleDaily.steps` above flattens a whole day to one total, so a
// dead/absent phone for part of a day (e.g. the phone died mid-hike) is invisible. Walk the
// same window at HOURLY granularity into `appleStepHour` so a UI can show exactly which hours
// were recorded. First run ever (UserDefaults flag unset) widens the window to 90 days back —
// HealthKit retains hourly statistics historically, so this answers PAST days retroactively;
// every later sync only re-covers the normal start...end window like the daily collectors above.
let hourlyStepsBackfilled = UserDefaults.standard.bool(forKey: HealthKitBridge.hourlyStepsBackfilledKey)
let hourlyStepsStart: Date = hourlyStepsBackfilled ? start
: (cal.date(byAdding: .day, value: -90, to: cal.startOfDay(for: end)) ?? start)

// Build + upsert the store rows under the apple-health source.
let appleRows = byDay.map { (day, a) in
AppleDaily(day: day, steps: a.steps.map { Int($0) },
Expand Down Expand Up @@ -429,10 +444,25 @@ final class HealthKitBridge: ObservableObject {
// lastSync — a false "success", and the next delta sync skipped the window. (Reimplemented
// from @vulnix0x4's PR #375.)
do {
// Collect hourly steps here (not before) so a transient HealthKit error throws to the
// catch block and prevents the backfill flag from permanently locking out the 90-day window.
let hourlySteps = try await collectHourlySteps(start: hourlyStepsStart, end: end)

try await store.upsertAppleDaily(appleRows, deviceId: appleDeviceId)
try await store.upsertDailyMetrics(dmRows, deviceId: appleDeviceId)
try await store.upsertMetricSeries(points, deviceId: appleDeviceId)
if !workoutRows.isEmpty { try await store.upsertWorkouts(workoutRows, deviceId: appleDeviceId) }
// Only mark the backfill done once hourly data actually lands. HealthKit returns EMPTY
// (not an error) when step read-access is denied, and users grant Health scopes
// incrementally — so gating on non-empty, not just no-throw, stops a deny-then-grant
// sequence from burning the one-time 90-day widen before the user ever authorizes steps.
// A genuinely step-less window just re-scans next sync: cheap, bounded, self-healing.
if !hourlySteps.isEmpty {
try await store.upsertAppleStepHours(hourlySteps, deviceId: appleDeviceId)
if !hourlyStepsBackfilled {
UserDefaults.standard.set(true, forKey: HealthKitBridge.hourlyStepsBackfilledKey)
}
}
try await writeBack(whoopStore: store)
lastSync = Date()
lastError = nil
Expand Down Expand Up @@ -875,6 +905,43 @@ final class HealthKitBridge: ObservableObject {
}
}

/// Hourly cumulative step counts over `[start, end)`, mirroring `collect()`'s
/// `HKStatisticsCollectionQuery` shape but bucketed by HOUR instead of day so a backfill can show
/// exactly which hours the phone was recording. `anchorDate` is local midnight — same anchor
/// `collect()` uses for its daily buckets — so hour buckets fall on local-clock hour boundaries,
/// not UTC ones. Throws on a HealthKit query error (distinguished from a genuinely-empty 90-day
/// window, which returns zero rows); a transient error will propagate to the sync's existing
/// `catch` block, preventing the backfill flag from permanently locking out the 90-day window.
private func collectHourlySteps(start: Date, end: Date) async throws -> [(ts: Int, steps: Int)] {
guard let type = HKQuantityType.quantityType(forIdentifier: .stepCount) else { return [] }
let cal = Calendar.current
let anchor = cal.startOfDay(for: start)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate),
Self.notNoopAuthored,
])
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[(ts: Int, steps: Int)], Error>) in
let q = HKStatisticsCollectionQuery(quantityType: type, quantitySamplePredicate: predicate,
options: .cumulativeSum, anchorDate: anchor,
intervalComponents: DateComponents(hour: 1))
q.initialResultsHandler = { _, results, error in
if let error {
cont.resume(throwing: error)
return
}
var rows: [(ts: Int, steps: Int)] = []
results?.enumerateStatistics(from: start, to: end) { stats, _ in
if let sum = stats.sumQuantity() {
let steps = Int(sum.doubleValue(for: .count()).rounded())
rows.append((ts: Int(stats.startDate.timeIntervalSince1970), steps: steps))
}
}
cont.resume(returning: rows)
}
store.execute(q)
}
}

// MARK: - Workouts (#835)

/// Read the workouts the user logged in Apple Health over `[start, end)` and map each to a
Expand Down
21 changes: 21 additions & 0 deletions android/app/src/main/java/com/noop/data/Entities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,27 @@ data class RawImuSampleEntity(
}
}

/**
* Cached hourly Apple-Health step count (v31 / MIGRATION_24_25). Swift `appleStepHour`
* (WhoopStore Database.swift v31-apple-step-hour migration). `ts` is the hour-BUCKET START
* (wall-clock unix seconds, local-hour aligned by the HealthKit collection query); `steps` is the
* cumulative step sum within that hour. Natural key (deviceId, ts) mirrors every other per-sample
* table so the hourly upsert is idempotent. `appleDaily.steps` answers "how many steps that day";
* this table answers "which HOURS were recorded", so a dead/absent phone for part of a day is
* visible instead of a single flattened daily total.
*
* Fields are declared in the SAME order as the Swift GRDB schema (deviceId, ts, steps) so the
* migration's CREATE TABLE column order matches Room's generated shape. The Apple-Health IMPORT that
* populates it is iOS-only (HealthKit has no Android analogue), so Android carries the SCHEMA for
* .noopbak byte-parity but no importer writes to it.
*/
@Entity(tableName = "appleStepHour", primaryKeys = ["deviceId", "ts"])
data class AppleStepHour(
val deviceId: String,
val ts: Long,
val steps: Int,
)

/**
* One Live Session (silent guardian) record (v22 / MIGRATION_15_16). Natural key (deviceId, startTs).
* `endTs` is null while the session is still in progress. Fields are declared in the SAME order as the
Expand Down
Loading