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
19 changes: 19 additions & 0 deletions Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,25 @@ public enum HealthWriteback {
appleHealthExternalUUID(kind: metricId, identity: day)
}

/// How close to the newest heart rate a night may end and still be read as unfinished.
public static let openNightMarginSeconds = 20 * 60

/// How long after its end a night is final even with no newer heart rate, so a strap taken off at
/// wake (charging, say) does not hold the night back indefinitely.
public static let openNightMaxHoldSeconds = 2 * 3_600

/// Whether a detected night may still be growing: it ends where the synced heart rate ends.
///
/// A night is detected from whatever has synced so far, so a pass during the night ends it at the
/// newest sample. That truncated night used to reach Apple Health like a finished one, and a reader
/// took its end as the wake: a field night slept to 09:36 was in Health as ending 06:53, with vitals
/// scored from the first six hours, until a later write-back replaced it. Holding a night whose end
/// sits within `openNightMarginSeconds` of the newest heart rate keeps it out until the strap has
/// seen the wearer awake; after `openNightMaxHoldSeconds` it is written regardless.
public static func nightIsStillOpen(endTs: Int, newestHeartRateTs: Int, now: Int) -> Bool {
newestHeartRateTs - endTs < openNightMarginSeconds && now - endTs < openNightMaxHoldSeconds
}

/// The sleep key: `noop:sleep:<startTs>`.
public static func appleHealthSleepKey(startTs: Int) -> String {
appleHealthExternalUUID(kind: "sleep", identity: "\(startTs)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,4 +339,19 @@ final class HealthWritebackTests: XCTestCase {
swept = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: ["sleepAnalysis"])
XCTAssertEqual(swept, ["restingHeartRate", "sleepAnalysis"])
}

func testANightEndingAtTheNewestHeartRateIsStillOpen() {
// Synced to 06:54 while the wearer slept on: the detected night ends at 06:53.
XCTAssertTrue(HealthWriteback.nightIsStillOpen(endTs: 1_000, newestHeartRateTs: 1_060, now: 1_600))
}

func testANightWithHeartRateWellPastItsEndIsClosed() {
XCTAssertFalse(HealthWriteback.nightIsStillOpen(endTs: 1_000, newestHeartRateTs: 1_000 + 20 * 60, now: 3_000))
}

func testANightIsWrittenAfterTheMaximumHoldEvenWithNoNewerHeartRate() {
// Strap taken off to charge at wake: no newer heart rate arrives.
XCTAssertTrue(HealthWriteback.nightIsStillOpen(endTs: 1_000, newestHeartRateTs: 1_000, now: 1_000 + 2 * 3_600 - 1))
XCTAssertFalse(HealthWriteback.nightIsStillOpen(endTs: 1_000, newestHeartRateTs: 1_000, now: 1_000 + 2 * 3_600))
}
}
20 changes: 16 additions & 4 deletions StrandiOS/Health/HealthKitBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,18 @@ final class HealthKitBridge: ObservableObject {
var sleepsByStart: [Int: CachedSleepSession] = [:]
for s in computedSleeps { sleepsByStart[s.startTs] = s }
for s in importedSleeps { sleepsByStart[s.startTs] = s }
let sessions = sleepsByStart.keys.sorted().map { sleepsByStart[$0]! }
// A night the strap may still be recording is held back, with its day's vitals, until it closes
// (`HealthWriteback.nightIsStillOpen`).
// The strap's own heart rate, the same stream the HR write reads, so an Apple Watch still recording
// cannot make a strap night that stopped at its sync frontier look finished.
let newestHeartRateTs = (try? await whoopStore.hrFingerprint(deviceId: noopDeviceId, from: nowTs - 2 * 86_400,
to: nowTs).maxTs) ?? 0
let openStarts = Set(computedSleeps.filter {
HealthWriteback.nightIsStillOpen(endTs: $0.endTs, newestHeartRateTs: newestHeartRateTs, now: nowTs)
}.map(\.startTs))
let sessions = sleepsByStart.keys.sorted().filter { !openStarts.contains($0) }.map { sleepsByStart[$0]! }
let openDays = Set(computedSleeps.filter { openStarts.contains($0.startTs) }
.map { HealthKitBridge.dayString(Date(timeIntervalSince1970: TimeInterval($0.endTs))) })

var firstError: Error?
func attempt(_ op: () async throws -> Void) async {
Expand All @@ -802,7 +813,7 @@ final class HealthKitBridge: ObservableObject {
// the HR path uses), then the normal writes re-add them under the new keys. Runs once,
// gated by a UserDefaults flag, BEFORE the new-key writes so nothing is lost.
await attempt { try await migrateStrandedHealthRecords(fromTs: fromTs, nowTs: nowTs) }
await attempt { try await writeVitals(whoopStore: whoopStore, days: days, sessions: sessions) }
await attempt { try await writeVitals(whoopStore: whoopStore, days: days, sessions: sessions, holdingDays: openDays) }
await attempt { try await writeSleep(sessions: sessions) }
await attempt { try await writeHeartRate(whoopStore: whoopStore, fromTs: fromTs, nowTs: nowTs) }
await attempt { try await writeWorkouts(whoopStore: whoopStore, fromTs: fromTs, toTs: nowTs) }
Expand Down Expand Up @@ -881,7 +892,8 @@ final class HealthKitBridge: ObservableObject {
/// The nightly vitals write (the original write-back), now stamped at the day's wake time when
/// that day has a sleep session — a real timestamp inside the night the value describes, instead
/// of a fabricated noon. Keys are unchanged, so re-stamped samples replace their noon ancestors.
private func writeVitals(whoopStore: WhoopStore, days: Int, sessions: [CachedSleepSession]) async throws {
private func writeVitals(whoopStore: WhoopStore, days: Int, sessions: [CachedSleepSession],
holdingDays: Set<String> = []) async throws {
let cal = Calendar.current
let to = HealthKitBridge.dayString(Date())
guard let fromDate = cal.date(byAdding: .day, value: -days, to: Date()) else { return }
Expand All @@ -908,7 +920,7 @@ final class HealthKitBridge: ObservableObject {
// (RMSSD for a strap row) under the SDNN type, turning a right value into a wrong one on every day
// an import happened to cover (#2264).
for r in imported { byDay[r.day] = HealthExportMerge.merged(computed: byDay[r.day], imported: r) }
let rows = byDay.keys.sorted().map { byDay[$0]! }
let rows = byDay.keys.sorted().filter { !holdingDays.contains($0) }.map { byDay[$0]! }

struct Candidate { let type: HKQuantityType; let key: String; let sample: HKQuantitySample }
var candidates: [Candidate] = []
Expand Down
27 changes: 26 additions & 1 deletion Tools/parity_dispositions.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
{
"schema_version": 1,
"dispositions": []
"dispositions": [
{
"type": "platform_specific",
"kind": "add-unpaired-constant",
"identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::openNightMarginSeconds",
"identity_sha256": "7f2231de6355adc6b5897cb6f0addc8b708bf08dd0277f465dcb794f4fd29b2a",
"platform": "swift",
"rationale": "Used only by the iOS HealthKitBridge write-back to hold a still-open night out of Apple Health; the Android Health Connect exporter does not call it (this PR is Swift-only)."
},
{
"type": "platform_specific",
"kind": "add-unpaired-constant",
"identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::openNightMaxHoldSeconds",
"identity_sha256": "a497335d1554bb0604aa79b1ae05fedaca8d0a812141c038e7e5151d2a8348e5",
"platform": "swift",
"rationale": "Used only by the iOS HealthKitBridge write-back to hold a still-open night out of Apple Health; the Android Health Connect exporter does not call it (this PR is Swift-only)."
},
{
"type": "platform_specific",
"kind": "add-unpaired-function",
"identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::nightIsStillOpen/3#1",
"identity_sha256": "88a1bdfb13c227664f15d962fb6d41a5733fb4c2956e445e42c5e4d495bde0cb",
"platform": "swift",
"rationale": "Used only by the iOS HealthKitBridge write-back to hold a still-open night out of Apple Health; the Android Health Connect exporter does not call it (this PR is Swift-only)."
}
]
}
8 changes: 4 additions & 4 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@
},
"authority": {
"files": {"count": 502, "sha256": "322bb433fab56fef5ec926d7847faef78e4c64ea409d161184baaf483c8eec95"},
"functions": {"count": 4454, "sha256": "a175ba96341d9de11cdc1e20c6f31685901f23528de7e05437031fca734b77cd"},
"functions": {"count": 4455, "sha256": "a07ca22b2e581e8b6feadae9bdb1e4d645d46196921796df3a2be6cdf8c2ef7b"},
"properties": {"count": 458, "sha256": "d654302949fe0cb34f6e43a757e3a47fe485758e8a902c247285c0e288964d45"},
"constants": {"count": 1949, "sha256": "8c16a5b3b823c7ba3186207425c6a3a28c9c7282e023940f394010ec65bb818c"},
"constants": {"count": 1951, "sha256": "aa7ce58efe6a8d3409abd3ccad24d22889514d4555faca323a0ee751ff7925b5"},
"file_pairs": {"count": 68, "sha256": "414dbafb27e1e35cf65cff54f6ff780f102f009762c1dfb12d91b0980fe30854"},
"function_pairs": {"count": 176, "sha256": "e3a74634d5a9381cf6e3491df839dad5ab33ec6cee8f29eb77c47ba7974b2e76"},
"property_pairs": {"count": 148, "sha256": "6fa59982fca9e8e306562b9d027676457c9fa4cc6766c1d6171e86084f3b2c54"},
"constant_pairs": {"count": 678, "sha256": "350d339d5fca3416a600ca96939a8ae8d269201e83ab5b05a8accbc6d3f85aa2"},
"unpaired_files": {"count": 384, "sha256": "17285ce29f015a373969bbcb13042b100a15d580825785f824680e0880b5d777"},
"unpaired_functions": {"count": 4108, "sha256": "74048168f484f4979028c981c620b3dcf0603db3bfc33bc96428086b5ead5272"},
"unpaired_functions": {"count": 4109, "sha256": "a3ec845a9b802edc70c7f018b6ecd56889022b7d95e9738c29dbe0fde6d4af5e"},
"unpaired_properties": {"count": 162, "sha256": "eede893a804203b080f27bb05ca6a09ee9ac9d708ade984e2855813f35b6ae22"},
"unpaired_constants": {"count": 593, "sha256": "d60473ce841fbc73367d983b485f573bbc4ef283336ecb4fbed7de7607620448"}
"unpaired_constants": {"count": 595, "sha256": "f065b8e34db78569a84449444539956cb48df105c9b66274398422cf8c520bc7"}
}
}
Loading