diff --git a/Packages/StrandImport/Sources/StrandImport/NoopHealthSleepExport.swift b/Packages/StrandImport/Sources/StrandImport/NoopHealthSleepExport.swift new file mode 100644 index 000000000..d1687880b --- /dev/null +++ b/Packages/StrandImport/Sources/StrandImport/NoopHealthSleepExport.swift @@ -0,0 +1,86 @@ +import Foundation + +// MARK: - Sleep → Apple Health export mapping (pure, platform-agnostic) +// +// The iOS `HealthKitBridge` write-back can mirror NOOP's strap-detected sleep into Apple Health as +// `sleepAnalysis` category samples. The *parsing* of a session's `stagesJSON` into a stage timeline +// is pure Foundation (no HealthKit), so it lives here in StrandImport where it compiles on every +// platform and can be unit-tested on the macOS test host. The bridge maps `NoopSleepStageKind` +// onto `HKCategoryValueSleepAnalysis`. + +/// One sleep stage as NOOP records it, independent of HealthKit. `core` is Apple's name for what the +/// on-device stager calls "light"; `asleepUnspecified` is the whole-session fallback used when a +/// session carries no decodable stage timeline. +public enum NoopSleepStageKind: String, Sendable, Equatable, CaseIterable { + case awake + case core + case deep + case rem + case asleepUnspecified +} + +/// One absolute-time stage span. `start`/`end` are wall-clock dates (derived from the session's unix +/// timestamps), ready to hand to a health store as a sample interval. +public struct NoopSleepInterval: Sendable, Equatable { + public let start: Date + public let end: Date + public let kind: NoopSleepStageKind + public init(start: Date, end: Date, kind: NoopSleepStageKind) { + self.start = start + self.end = end + self.kind = kind + } +} + +public enum NoopHealthSleepExport { + + /// Resolve a session's `stagesJSON` into absolute-time stage intervals suitable for writing to a + /// health store. + /// + /// Two `stagesJSON` shapes exist in the store (see `SleepView`): the COMPUTED segment array + /// `[{"start":epoch,"end":epoch,"stage":"wake"|"light"|"deep"|"rem"}]` carries a real timeline and + /// maps 1:1 onto per-stage intervals; the IMPORTED minutes dict + /// `{"light":..,"deep":..,"rem":..,"awake":..}` carries no timeline. When the segment array can't + /// be decoded (nil / empty / the minutes-dict form), we fall back to a single + /// `.asleepUnspecified` interval spanning `[sessionStart, sessionEnd]` so every night still lands + /// in Apple Health rather than being silently dropped. + /// + /// Returns `[]` only when there is nothing writable at all — no decodable segments AND a + /// degenerate span (`sessionEnd <= sessionStart`). + public static func stageIntervals(stagesJSON: String?, + sessionStart: Date, + sessionEnd: Date) -> [NoopSleepInterval] { + if let segments = segmentIntervals(stagesJSON) { return segments } + guard sessionEnd > sessionStart else { return [] } + return [NoopSleepInterval(start: sessionStart, end: sessionEnd, kind: .asleepUnspecified)] + } + + /// Parse the COMPUTED segment-array `stagesJSON` into intervals, or nil when the string isn't that + /// shape (so the caller can fall back to a whole-session span). Segment `start`/`end` are absolute + /// unix seconds. Stage names mirror `SleepView.decodeSegments` exactly ("wake"/"awake" → awake, + /// "light" → core, "deep" → deep, "rem" → rem); any other name, or a non-positive span, is skipped. + static func segmentIntervals(_ json: String?) -> [NoopSleepInterval]? { + guard let json, let data = json.data(using: .utf8), + let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]], + !arr.isEmpty else { return nil } + var out: [NoopSleepInterval] = [] + for seg in arr { + guard let start = (seg["start"] as? NSNumber)?.intValue, + let end = (seg["end"] as? NSNumber)?.intValue, end > start, + let name = seg["stage"] as? String else { continue } + let kind: NoopSleepStageKind + switch name { + case "wake", "awake": kind = .awake + case "light": kind = .core + case "deep": kind = .deep + case "rem": kind = .rem + default: continue + } + out.append(NoopSleepInterval( + start: Date(timeIntervalSince1970: TimeInterval(start)), + end: Date(timeIntervalSince1970: TimeInterval(end)), + kind: kind)) + } + return out.isEmpty ? nil : out + } +} diff --git a/Packages/StrandImport/Tests/StrandImportTests/NoopHealthSleepExportTests.swift b/Packages/StrandImport/Tests/StrandImportTests/NoopHealthSleepExportTests.swift new file mode 100644 index 000000000..16e7337cf --- /dev/null +++ b/Packages/StrandImport/Tests/StrandImportTests/NoopHealthSleepExportTests.swift @@ -0,0 +1,80 @@ +import XCTest +@testable import StrandImport + +final class NoopHealthSleepExportTests: XCTestCase { + + // A fixed session window used across the fallback cases: 23:00 → 07:00 (8h). + private let start = Date(timeIntervalSince1970: 1_700_000_000) // arbitrary night onset + private var end: Date { start.addingTimeInterval(8 * 3600) } // +8h wake + + // MARK: - Computed segment array (real timeline) + + func testSegmentArrayDecodesEveryStageWithAbsoluteTimes() { + let s = Int(start.timeIntervalSince1970) + let json = """ + [ + {"start":\(s), "end":\(s + 3600), "stage":"light"}, + {"start":\(s + 3600), "end":\(s + 5400), "stage":"deep"}, + {"start":\(s + 5400), "end":\(s + 7200), "stage":"rem"}, + {"start":\(s + 7200), "end":\(s + 7500), "stage":"wake"} + ] + """ + let ivs = NoopHealthSleepExport.stageIntervals(stagesJSON: json, sessionStart: start, sessionEnd: end) + + XCTAssertEqual(ivs.map(\.kind), [.core, .deep, .rem, .awake]) + // "light" maps to Apple's "core", "wake" maps to "awake". + XCTAssertEqual(ivs[0].start, start) + XCTAssertEqual(ivs[0].end, start.addingTimeInterval(3600)) + XCTAssertEqual(ivs[3].kind, .awake) + // A real timeline never collapses to the whole-session fallback. + XCTAssertFalse(ivs.contains { $0.kind == .asleepUnspecified }) + } + + func testUnknownStageAndNonPositiveSpansAreSkipped() { + let s = Int(start.timeIntervalSince1970) + let json = """ + [ + {"start":\(s), "end":\(s + 1800), "stage":"deep"}, + {"start":\(s + 1800), "end":\(s + 1800), "stage":"rem"}, + {"start":\(s + 1800), "end":\(s + 3600), "stage":"unknown-stage"} + ] + """ + let ivs = NoopHealthSleepExport.stageIntervals(stagesJSON: json, sessionStart: start, sessionEnd: end) + // Only the first (valid, positive-span, known-stage) segment survives. + XCTAssertEqual(ivs.map(\.kind), [.deep]) + } + + // MARK: - Fallback to a single whole-session span + + func testImportedMinutesDictFallsBackToUnspecifiedSpan() { + // The imported shape carries no timeline, so we can't reconstruct stages — one span instead. + let json = #"{"light":210,"deep":75,"rem":95,"awake":20}"# + let ivs = NoopHealthSleepExport.stageIntervals(stagesJSON: json, sessionStart: start, sessionEnd: end) + XCTAssertEqual(ivs.count, 1) + XCTAssertEqual(ivs.first?.kind, .asleepUnspecified) + XCTAssertEqual(ivs.first?.start, start) + XCTAssertEqual(ivs.first?.end, end) + } + + func testNilAndEmptyJSONFallBackToUnspecifiedSpan() { + for json in [nil, "", "[]", "not json"] as [String?] { + let ivs = NoopHealthSleepExport.stageIntervals(stagesJSON: json, sessionStart: start, sessionEnd: end) + XCTAssertEqual(ivs.count, 1, "json=\(String(describing: json))") + XCTAssertEqual(ivs.first?.kind, .asleepUnspecified) + } + } + + func testDegenerateSpanWithNoSegmentsYieldsNothing() { + // No decodable segments AND end <= start: nothing writable. + let ivs = NoopHealthSleepExport.stageIntervals(stagesJSON: nil, sessionStart: start, sessionEnd: start) + XCTAssertTrue(ivs.isEmpty) + } + + func testDegenerateSpanStillHonoursARealSegmentTimeline() { + // Even if the caller passes a collapsed [start,end], a real segment array must still export. + let s = Int(start.timeIntervalSince1970) + let json = #"[{"start":\#(s),"end":\#(s + 600),"stage":"rem"}]"# + let ivs = NoopHealthSleepExport.stageIntervals(stagesJSON: json, sessionStart: start, sessionEnd: start) + XCTAssertEqual(ivs.map(\.kind), [.rem]) + } +} diff --git a/Strand/Screens/AppleHealthView.swift b/Strand/Screens/AppleHealthView.swift index 64f524990..b55d680f2 100644 --- a/Strand/Screens/AppleHealthView.swift +++ b/Strand/Screens/AppleHealthView.swift @@ -46,6 +46,11 @@ struct AppleHealthView: View { // property and every `health.*` use below MUST stay inside `#if os(iOS)`. #if os(iOS) @EnvironmentObject private var health: HealthKitBridge + /// Opt-in: write NOOP's FULL mappable data set (core vitals + strap-detected sleep stages) into + /// Apple Health automatically on every sync. Persisted under the same key the bridge reads + /// (`HealthKitBridge.writeAllData`); defaults off so nothing beyond the core vitals is written + /// until the user turns it on. Toggling it kicks an immediate sync so the choice takes effect now. + @AppStorage(HealthKitBridge.writeAllDataDefaultsKey) private var writeAllToHealth = false #endif // Imperial/Metric display preference (D#103). Weight and lean mass (stored kg) re-label to lb here; @@ -454,6 +459,37 @@ struct AppleHealthView: View { .buttonStyle(.bordered) .tint(StrandPalette.metricCyan) .disabled(health.syncing) + + Divider().overlay(StrandPalette.hairline) + + // "Add all data to Apple Health, automatically." NOOP always writes its core vitals + // (resting HR, HRV, blood oxygen, respiratory rate) on each sync; this adds the full + // strap-detected sleep stages to that write-back, on every automatic sync. Recovery + // and Strain are NOOP-only scores with no Apple Health type, so they stay in NOOP. + Toggle(isOn: $writeAllToHealth) { + VStack(alignment: .leading, spacing: 3) { + Text("Write all data automatically") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + Text(writeAllToHealth + ? "Vitals and full sleep stages are written to Apple Health on every sync." + : "Core vitals only. Turn on to also add your sleep stages, automatically.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + .tint(StrandPalette.metricCyan) + .disabled(health.syncing) + // Apply the choice immediately: switching on runs a sync so this session's sleep + // lands in Health now rather than waiting for the next foreground refresh. + .onChangeCompat(of: writeAllToHealth) { on in + guard on else { return } + Task { + await health.sync() + await load() + } + } } if let err = health.lastError { diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index 02ac07f62..a4d04deca 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -43,6 +43,16 @@ final class HealthKitBridge: ObservableObject { /// `noopDeviceId` daily row, so those metrics exist ONLY here. private var computedDeviceId: String { noopDeviceId + "-noop" } + /// UserDefaults key for the opt-in "write my full data set to Apple Health, automatically" + /// preference. Read by `sync` to decide whether to run the extended `writeSleepBack` pass; bound by + /// the Apple Health screen's toggle (`AppleHealthView.liveSyncCard`). Defaults to false, so the + /// write-back stays the minimal core-vitals set until the user opts in. + static let writeAllDataDefaultsKey = "health.writeAllData.v1" + + /// True when the user has opted into writing NOOP's full mappable data set (core vitals PLUS the + /// strap-detected sleep stages) into Apple Health on every sync. Off by default. + var writeAllData: Bool { UserDefaults.standard.bool(forKey: Self.writeAllDataDefaultsKey) } + init(repo: Repository, appleDeviceId: String, noopDeviceId: String) { self.repo = repo self.appleDeviceId = appleDeviceId @@ -403,6 +413,11 @@ final class HealthKitBridge: ObservableObject { try await store.upsertMetricSeries(points, deviceId: appleDeviceId) if !workoutRows.isEmpty { try await store.upsertWorkouts(workoutRows, deviceId: appleDeviceId) } try await writeBack(whoopStore: store) + // Opt-in "write all data" pass: when enabled, ALSO mirror NOOP's strap-detected sleep + // stages into Apple Health. Kept inside the same round-trip so a sleep-save failure + // surfaces in lastError and does NOT advance lastSync (a false "success" would let the + // next delta sync skip the window). No-op unless the user turned the toggle on. + if writeAllData { try await writeSleepBack(whoopStore: store) } lastSync = Date() lastError = nil } catch { @@ -487,6 +502,80 @@ final class HealthKitBridge: ObservableObject { try await self.store.save(candidates.map { $0.sample }) } + /// Extended write-back (opt-in via `writeAllData`): mirror NOOP's strap-detected sleep into Apple + /// Health as `sleepAnalysis` category samples — the full per-stage timeline when the session + /// carries one, otherwise a single asleep span. Sleep-share permission is ALREADY requested in + /// `writeTypes`, so this needs no new consent prompt; it simply activates a scope the bridge asked + /// for but never wrote. + /// + /// Dedup mirrors `writeBack`: each sample carries a deterministic `HKMetadataKeyExternalUUID` + /// (`noop::sleep::`); before saving we delete any of OUR + /// prior samples with those keys, scoped to `HKSource.default()` so another app's sleep is never + /// touched. Re-syncing a night therefore refreshes rather than duplicates. + /// + /// Throws on save failure so the caller keeps `lastSync` honest. + private func writeSleepBack(whoopStore: WhoopStore, days: Int = 14) async throws { + guard auth == .authorized else { return } + guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else { return } + let cal = Calendar.current + let now = Date() + guard let fromDate = cal.date(byAdding: .day, value: -days, to: now) else { return } + let fromTs = Int(fromDate.timeIntervalSince1970) + let toTs = Int(now.timeIntervalSince1970) + + // Union the COMPUTED sleep sessions (a strap-only user's nights live under `deviceId + "-noop"`) + // with any IMPORTED ones, imported overriding by onset — the same source precedence `writeBack` + // uses for the daily vitals. + let computed = (try? await whoopStore.sleepSessions(deviceId: computedDeviceId, from: fromTs, to: toTs, limit: 5000)) ?? [] + let imported = (try? await whoopStore.sleepSessions(deviceId: noopDeviceId, from: fromTs, to: toTs, limit: 5000)) ?? [] + var byOnset: [Int: CachedSleepSession] = [:] + for s in computed { byOnset[s.effectiveStartTs] = s } // computed first + for s in imported { byOnset[s.effectiveStartTs] = s } // imported overrides + let sessions = byOnset.keys.sorted().map { byOnset[$0]! } + + var samples: [HKCategorySample] = [] + var keys: [String] = [] + for session in sessions { + let start = Date(timeIntervalSince1970: TimeInterval(session.effectiveStartTs)) + let end = Date(timeIntervalSince1970: TimeInterval(session.endTs)) + // Pure, testable parse of stagesJSON → absolute-time stage intervals (StrandImport). + let intervals = NoopHealthSleepExport.stageIntervals( + stagesJSON: session.stagesJSON, sessionStart: start, sessionEnd: end) + for (i, iv) in intervals.enumerated() { + let value = Self.sleepCategoryValue(iv.kind) + let key = "noop:\(noopDeviceId):sleep:\(session.effectiveStartTs):\(i)" + let sample = HKCategorySample( + type: sleepType, value: value.rawValue, + start: iv.start, end: iv.end, + metadata: [HKMetadataKeyExternalUUID: key]) + samples.append(sample) + keys.append(key) + } + } + guard !samples.isEmpty else { return } + + // Delete OUR prior sleep samples for these keys, then save the fresh batch. Scoped to this + // app's own source so we never delete Apple Watch / another app's sleep. + let bySource = HKQuery.predicateForObjects(from: HKSource.default()) + let byKey = HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID, + allowedValues: Array(Set(keys))) + let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [bySource, byKey]) + _ = try? await self.store.deleteObjects(of: sleepType, predicate: pred) + try await self.store.save(samples) + } + + /// Map a NOOP stage onto HealthKit's sleep-analysis category value. `core` is Apple's name for the + /// stager's "light"; the whole-session fallback writes as `asleepUnspecified`. + private static func sleepCategoryValue(_ kind: NoopSleepStageKind) -> HKCategoryValueSleepAnalysis { + switch kind { + case .awake: return .awake + case .core: return .asleepCore + case .deep: return .asleepDeep + case .rem: return .asleepREM + case .asleepUnspecified: return .asleepUnspecified + } + } + private struct DayAgg { var restingHr: Double?; var avgHr: Double?; var maxHr: Double?; var hrv: Double? var spo2: Double?; var respRate: Double?; var steps: Double?