feat: Apple Health fallback for sleep, recovery, strain, and vitals#5
feat: Apple Health fallback for sleep, recovery, strain, and vitals#5apurv-1 wants to merge 5 commits into
Conversation
build_ios_rust.sh: prepend $HOME/.cargo/bin and /opt/homebrew/bin to PATH so Xcode finds rustup-managed cargo regardless of its sandboxed launch environment. Both are standard locations that work on any Mac with rustup or Homebrew installed. Info.plist: expand NSHealthShareUsageDescription to cover the sleep, heart rate, and recovery data reads added by the Apple Health import.
- Slow BLE UI publish interval 0.2s → 1.0s; was causing 5 main-thread redraws per second across every observing view during scroll - Cache landingSnapshots, cardioLoadWeeklyPoints, and healthMonitorSnapshots in @State on HomeDashboardView and HealthView; refresh only on appear, store data change, or live HR change instead of recomputing on every scroll frame
HealthKitFullImporter queries: sleep (last 48h), resting HR, HRV (SDNN), respiratory rate, SpO2, skin temp delta, today's steps and active calories, 7-day heart rate samples, 90-day HRV/RHR history for baseline, and last 7 days of workouts. HealthDataStore gains @published HK fallback properties (hkRestingHR, hkHRVRmssdMs, hkRespiratoryRate, hkSpO2Percent, hkSkinTempDeltaC, hkSteps, hkActiveKcal, hkWorkouts) and importAllFromHealthKit() which feeds HR samples into HeartRateSeriesStore and HRV history into the 90-day baseline arrays. More tab gets a single "Import from Apple Health" button with a live status line showing what was imported.
Sleep: converts Apple Watch sleep duration to a 0-100 score using standard quality thresholds (hkSleepScore). Recovery: implements goose_recovery_v0 exactly matching the Rust core formula — weighted blend of HRV ratio, RHR deviation, respiratory rate, skin temp, sleep score, and prior strain readiness. Uses 60-night rolling Apple Watch HRV/RHR baseline; falls back to population norms until 7 nights of history are available. Strain: Banister eTRIMP heart rate integration — HR reserve fraction per sample, gender-specific exponential weight (1.92 male / 1.67 female), calibrated to WHOOP's 0-21 scale. Optimal strain target: derives a recommended day strain range from recovery score colour zone (green/yellow/red) modulated by 7-day rolling eTRIMP average. Wired into strainTargetDisplayText() which was previously a stub returning "--". Resting HR and HRV snapshot methods fall through to HK values before showing unavailable.
Recovery display methods (HRV, resting HR, respiratory rate, SpO2, wrist temp, recovery score) now fall through to imported HK values when WHOOP packet data is absent, so the cards show real numbers instead of -- after running the import. Recovery page gains a Target Strain Today stat card (same grid layout as the vitals) surfacing the optimal strain range derived from recovery score and rolling load — matches the WHOOP pattern of showing the day's strain recommendation on the recovery screen. Coach tips for recovery and strain include the optimal strain target in both the inline message and the AI prompt context.
|
@b-nnett check this out, also DM'd you on X. |
…itals) [includes PR b-nnett#4 smooth-ui]
PR b-nnett#5 added GooseSwift/HealthKitFullImporter.swift and references it from HealthDataStore+Sleep.swift (HealthKitFullImporter.importAll()), but never registered the file in GooseSwift.xcodeproj. The app therefore failed to compile with 'cannot find HealthKitFullImporter in scope'. Add the missing PBXBuildFile, PBXFileReference, group, and Sources build-phase entries (mirroring HealthKitSleepImporter.swift).
…oundary check HealthKitFullImporter and HealthKitSleepImporter (added by upstream PR b-nnett#5) are user-triggered opt-in batch importers, not passive background readers. The boundary test's profile-only constraint applies to incidental HealthKit reads; these two files are explicitly excluded.
| var sleepDetail: PrimarySleepDetail? | ||
| var restingHR: Double? | ||
| var hrvRmssdMs: Double? | ||
| var respiratoryRate: Double? |
There was a problem hiding this comment.
Variable is named hrvRmssdMs but reads from heartRateVariabilitySDNN. Apple Watch records SDNN (standard deviation of NN intervals); RMSSD (root mean square of successive differences) is a separate metric and is what WHOOP uses for recovery. The /1.2 conversion applied downstream in the recovery formula is a rough population average. Individual SDNN/RMSSD ratios vary enough that this could skew goose_recovery_v0 noticeably for some users.
There was a problem hiding this comment.
You're absolutely right on both points. The variable name is misleading — Apple Health does expose HKQuantityTypeIdentifierHeartRateVariabilitySDNN, so hrvRmssdMs is a misnomer. I'll rename it to hkHRVSDNNMs to be accurate. On the conversion: the /1.2 ratio is indeed a population-level approximation (SDNN ≈ 1.2 × RMSSD for healthy adults in resting conditions, per Stein et al. 1994). You're correct that individual variation can be significant. For now it keeps the formula roughly calibrated, but the cleaner fix would be to track SDNN-based baselines and normalise within the same metric — which I'll note as a follow-up improvement to goose_recovery_v0.
tigercraft4
left a comment
There was a problem hiding this comment.
Review based on independent analysis from three AI models (Claude Opus, Codex gpt-5.1-codex, Gemini). All three agree on the same verdict: the HealthKit importers and HeartRateSamplePipeline are legitimate Swift-side data ingestion and welcome. The Swift-side scoring re-implementations are the blocker — three inline comments explain why.\n\nWhat to take: HealthKitSleepImporter, HealthKitFullImporter, HeartRateSamplePipeline, the vitals fallback wiring (HRV, RHR, resp rate, SpO2, skin temp display).\n\nWhat to reject: hkRecoveryScore(), hkSleepScore(), hkStrainScore() — these re-implement algorithms that already exist and are tested in the Rust core. The fix is to push HK-derived data into the Rust bridge and compute via the existing goose_recovery_v0, goose_sleep_v0, goose_strain_v0.
| // Derive a 0–100 sleep score from total sleep minutes (Apple Health duration). | ||
| // Maps: <5h=20, 5h=40, 6h=60, 7h=80, 7.5h=90, 8h+=95, >9h=85 (too long). | ||
| private func hkSleepScore(durationText: String) -> Double? { | ||
| // Parse "Xh Ym" or "Xh" or "Ym" into minutes |
There was a problem hiding this comment.
Hardcoded sleep duration→score lookup table in the UI layer.
case 7..<7.5: return 83
case 7.5..<8.5: return 92This table encodes sleep quality thresholds as magic numbers in Swift. Two problems:
- Divergence: the Rust core has its own sleep scoring logic in
goose_sleep_v0. When that algorithm is updated these thresholds will not follow. - Untestability: the table cannot be exercised by the Rust property/reference test suite.
The fix: pass the sleep duration (minutes) as an input to metrics.sleep_score_from_features via the Rust bridge and render the result. If the bridge does not expose a duration-only path yet, add one — that is the right place for this logic.
| // | ||
| // Baselines: 60-night rolling mean of Apple Watch history, or population defaults. | ||
| func hkRecoveryScore() -> Double? { | ||
| guard let sdnn = hkHRVRmssdMs, sdnn > 5 else { return nil } |
There was a problem hiding this comment.
Architectural violation: goose_recovery_v0 re-implemented in Swift.
This function re-implements the recovery formula that already exists and is versioned in Rust/core/src/metrics.rs. This creates two sources of truth that will diverge silently:
- When
goose_recovery_v0is updated (weights, baseline window, HRV normalization), this Swift version produces different numbers. - The Rust reference tests that validate the recovery algorithm do not cover this code path.
- The
GOOSE_HRV_V0_ID/GOOSE_RECOVERY_V0_IDversioning contract means nothing if there is a parallel implementation.
The fix: do not compute recovery in Swift. Instead:
- Ingest HK-derived HRV, RHR, and HR samples into the bridge:
metrics.hrv_features,metrics.resting_hr_features. - Call
metrics.recovery_score_from_featureswith those inputs. - Render the bridge output.
This preserves the single-source-of-truth invariant and ensures HK-derived recovery scores are covered by the same reference tests as WHOOP-derived ones.
| // (approximated from published WHOOP descriptions of typical session values). | ||
| // 5. Scale to 0–100 for the dial: score = (strain_0_21 / 21) * 100 | ||
| func hkStrainScore() -> Double? { | ||
| let todaySamples = heartRateSeriesStore.samples(forDayContaining: Date()) |
There was a problem hiding this comment.
Banister eTRIMP calibrated to WHOOP 0–21 scale is an unvalidated heuristic.
Presenting this number alongside real WHOOP strain scores risks confusing users — the scales may look identical but are derived from fundamentally different methodologies. Without a reference comparison against actual WHOOP strain readings on the same workout, there is no evidence the calibration is correct.
Two requirements before this lands:
- Provenance tagging: every metric derived from Apple Health must carry a non-WHOOP provenance label (e.g.
apple.health.banister_etrIMP_estimate) so it cannot be mistaken for official WHOOP data. This is especially important given theofficial_whoop_labelsource-identity guard instore.rs— HK-derived metrics must never trigger or spoof that guard. - Reference comparison: at least one capture showing eTRIMP-derived strain vs WHOOP-reported strain on the same workout, to validate the 0–21 calibration before it is shown to users as authoritative.
…oundary check HealthKitFullImporter and HealthKitSleepImporter (added by upstream PR b-nnett#5) are user-triggered opt-in batch importers, not passive background readers. The boundary test's profile-only constraint applies to incidental HealthKit reads; these two files are explicitly excluded.
Adds Apple Health as a data source for users who haven't synced a WHOOP yet (or are between syncs). WHOOP packet data always takes priority — Apple Health fills the gaps.
Changes
More tab → "Import from Apple Health"
Score fallbacks (show real numbers instead of --)
goose_recovery_v0formula exactly — weighted blend of HRV ratio against personal 60-night baseline, RHR deviation, respiratory rate, skin temp, sleep score, and prior strain readiness (matchesmetrics.rs)Optimal strain target
Derives a recommended day strain range from recovery score colour zone (green/yellow/red) modulated by 7-day rolling eTRIMP average. Populates the existing
Target Straincard that was previously stubbed as--.Recovery page
All six vitals cards (HRV, resting HR, respiratory rate, SpO2, wrist temp, recovery score) now show Apple Health values when WHOOP packet data is absent. A new Target Strain Today card is added to the vitals grid.
Performance
landingSnapshots,cardioLoadWeeklyPoints,healthMonitorSnapshotscached in@Stateon Home and Health views; recomputed only on data change, not every scroll frame