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
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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])
}
}
36 changes: 36 additions & 0 deletions Strand/Screens/AppleHealthView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
89 changes: 89 additions & 0 deletions StrandiOS/Health/HealthKitBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:<deviceId>:sleep:<sessionStart>:<segmentIndex>`); 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?
Expand Down