diff --git a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift index 5372f21c1b..fd8d5d0d3e 100644 --- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift +++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift @@ -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:`. public static func appleHealthSleepKey(startTs: Int) -> String { appleHealthExternalUUID(kind: "sleep", identity: "\(startTs)") diff --git a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift index db0be640c3..8366fed45f 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift @@ -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)) + } } diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index bcb3b8bfab..8d3e0cb27b 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -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 { @@ -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) } @@ -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 = []) async throws { let cal = Calendar.current let to = HealthKitBridge.dayString(Date()) guard let fromDate = cal.date(byAdding: .day, value: -days, to: Date()) else { return } @@ -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] = [] diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 036071b6f1..3dcf98fd10 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -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)." + } + ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index e1184c61f8..53f3770c23 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -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"} } }