Skip to content
Merged
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: - v38 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"]) }
}
}
}
21 changes: 21 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,27 @@ extension WhoopStore {
t.add(column: "avgSdnn", .double)
}
}
// v38-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.
migrator.registerMigration("v38-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 @@ -124,6 +124,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",
// v38-apple-step-hour: the hourly Apple Health step buckets are deviceId-keyed ("apple-health"),
// so forgetting that source must clear them — otherwise an imported phone's hour-by-hour step
// history survives the delete (the same privacy defect this list exists to close).
"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": 30,
"roomVersion": 31,
"grdbMigrations": [
"v1",
"v2",
Expand Down Expand Up @@ -38,7 +38,8 @@
"v34-sleep-staging-sparse",
"v35-rr-future-quarantine",
"v36-whoop-caps-no-spo2",
"v37-daily-avg-sdnn"
"v37-daily-avg-sdnn",
"v38-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 @@ -122,6 +123,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
75 changes: 75 additions & 0 deletions StrandiOS/Health/HealthKitBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ final class HealthKitBridge: ObservableObject {
/// UserDefaults key holding the read set the user was last asked about.
private static let readTypeSignatureKey = "noop.health.readTypeSignature"

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

/// A stable fingerprint of the read types currently requested.
private static var readTypeSignature: String {
quantityReadIds.map(\.rawValue).sorted().joined(separator: ",")
Expand Down Expand Up @@ -573,6 +578,31 @@ final class HealthKitBridge: ObservableObject {
CaffeineLogStore.shared.replaceImported(imported)
}
}
// Hourly step counts (v38-apple-step-hour). `appleDaily.steps` above flattens a whole day to
// one total, so an hour the phone spent dead/on a desk is invisible — the day just reads low.
// Walk the same window at HOURLY granularity into `appleStepHour` so a UI can show the shape
// of the day. First run ever (flag unset) widens to 90 days back: HealthKit keeps hourly
// statistics historically, so this answers PAST days retroactively rather than only from the
// day it ships; later syncs re-cover the normal start...end window like the daily collectors.
//
// Collected HERE (not earlier) so a transient HealthKit error throws into the existing catch
// below and the backfill flag is never set — a failed first run retries next sync instead of
// permanently skipping the one-time 90-day widen.
let hourlyStepsBackfilled = UserDefaults.standard.bool(forKey: Self.hourlyStepsBackfilledKey)
let hourlyStepsStart: Date = hourlyStepsBackfilled ? start
: (cal.date(byAdding: .day, value: -90, to: cal.startOfDay(for: end)) ?? start)
let hourlySteps = try await collectHourlySteps(start: hourlyStepsStart, end: end)
// 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 merely on no-throw, stops a deny-then-grant sequence from burning
// the one-time 90-day widen before the user ever authorises 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: Self.hourlyStepsBackfilledKey)
}
}
try await writeBack(whoopStore: store)
lastSync = Date()
lastError = nil
Expand Down Expand Up @@ -1007,6 +1037,51 @@ final class HealthKitBridge: ObservableObject {
/// the same empty result — and treating a failed query as an authoritative zero would wipe the
/// stored figure for the whole window.
@discardableResult
/// Hourly cumulative step counts over `[start, end)`, mirroring `collect()`'s
/// `HKStatisticsCollectionQuery` shape but bucketed by HOUR instead of day, so a backfill can show
/// the shape of a day rather than one flattened total. `anchorDate` is local midnight — the same
/// anchor `collect()` uses for its daily buckets — so hour buckets fall on local-clock hour
/// boundaries, not UTC ones.
///
/// WHAT A MISSING HOUR MEANS. Only hours with at least one step sample produce a row (HealthKit's
/// `sumQuantity()` is nil for an empty bucket). So an absent hour means "no steps were recorded" —
/// which covers a dead/left-behind phone AND an hour the wearer simply sat still. Step data carries
/// no separate "was recording" signal, so a consumer must not label a gap as "phone off" on its own;
/// it is evidence, not proof. Rows are never fabricated for empty hours.
///
/// Throws on a HealthKit query error (distinguished from a genuinely-empty window, which returns
/// zero rows) so a transient failure propagates to `sync()`'s existing `catch` instead of being
/// mistaken for "no data".
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)
}
}

private func collect(_ id: HKQuantityTypeIdentifier, unit: HKUnit, start: Date, end: Date,
op: HKStatisticsOptions, sink: @escaping (String, Double) -> Void) async -> Bool {
guard let type = HKQuantityType.quantityType(forIdentifier: id) else { return false }
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/java/com/noop/data/DeviceRegistry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ class DeviceRegistry(
dao.reKeyJournal(activeId, serialId); dao.deleteJournalFor(activeId)
dao.reKeyWorkouts(activeId, serialId); dao.deleteWorkoutsFor(activeId)
dao.reKeyAppleDaily(activeId, serialId); dao.deleteAppleDailyFor(activeId)
dao.reKeyAppleStepHour(activeId, serialId); dao.deleteAppleStepHoursFor(activeId)
dao.reKeyMetricSeries(activeId, serialId); dao.deleteMetricSeriesFor(activeId)
dao.reKeyDayOwnership(activeId, serialId); dao.deleteDayOwnershipFor(activeId)
dao.reKeySleepStates(activeId, serialId); dao.deleteSleepStatesFor(activeId)
Expand Down Expand Up @@ -191,6 +192,7 @@ class DeviceRegistry(
dao.deleteJournalFor(id)
dao.deleteWorkoutsFor(id)
dao.deleteAppleDailyFor(id)
dao.deleteAppleStepHoursFor(id)
dao.deleteMetricSeriesFor(id)
dao.deleteDayOwnershipFor(id)
dao.deleteScoreInputProvenanceFor(id)
Expand Down
5 changes: 5 additions & 0 deletions android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ interface DeviceRegistryDao {
@Query("DELETE FROM journal WHERE deviceId = :deviceId") suspend fun deleteJournalFor(deviceId: String)
@Query("DELETE FROM workout WHERE deviceId = :deviceId") suspend fun deleteWorkoutsFor(deviceId: String)
@Query("DELETE FROM appleDaily WHERE deviceId = :deviceId") suspend fun deleteAppleDailyFor(deviceId: String)
// v38-apple-step-hour: no Android importer writes this table, but a `.noopbak` restored FROM iOS
// carries its rows, so "delete this device's data" must clear them here too — otherwise the hourly
// step history survives a delete on Android alone (the defect this set exists to close).
@Query("DELETE FROM appleStepHour WHERE deviceId = :deviceId") suspend fun deleteAppleStepHoursFor(deviceId: String)
@Query("DELETE FROM metricSeries WHERE deviceId = :deviceId") suspend fun deleteMetricSeriesFor(deviceId: String)
@Query("DELETE FROM dayOwnership WHERE deviceId = :deviceId") suspend fun deleteDayOwnershipFor(deviceId: String)
@Query("DELETE FROM scoreInputProvenance WHERE deviceId = :deviceId OR sourceId = :deviceId")
Expand Down Expand Up @@ -122,6 +126,7 @@ interface DeviceRegistryDao {
@Query("UPDATE OR IGNORE journal SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyJournal(from: String, to: String)
@Query("UPDATE OR IGNORE workout SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyWorkouts(from: String, to: String)
@Query("UPDATE OR IGNORE appleDaily SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyAppleDaily(from: String, to: String)
@Query("UPDATE OR IGNORE appleStepHour SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyAppleStepHour(from: String, to: String)
@Query("UPDATE OR IGNORE metricSeries SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyMetricSeries(from: String, to: String)
@Query("UPDATE OR IGNORE dayOwnership SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyDayOwnership(from: String, to: String)
@Query("UPDATE OR IGNORE sleepStateSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeySleepStates(from: String, to: String)
Expand Down
Loading