Skip to content

feat: Apple Health fallback for sleep, recovery, strain, and vitals#5

Open
apurv-1 wants to merge 5 commits into
b-nnett:mainfrom
apurv-1:apple-health-integration
Open

feat: Apple Health fallback for sleep, recovery, strain, and vitals#5
apurv-1 wants to merge 5 commits into
b-nnett:mainfrom
apurv-1:apple-health-integration

Conversation

@apurv-1

@apurv-1 apurv-1 commented Jun 3, 2026

Copy link
Copy Markdown

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 --)

  • Sleep: duration-to-score conversion using standard quality thresholds
  • Recovery: implements the goose_recovery_v0 formula exactly — weighted blend of HRV ratio against personal 60-night baseline, RHR deviation, respiratory rate, skin temp, sleep score, and prior strain readiness (matches metrics.rs)
  • Strain: Banister eTRIMP — HR reserve fraction per sample with gender-specific exponential weight (1.92♂ / 1.67♀), 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. Populates the existing Target Strain card 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

  • BLE UI publish interval 0.2s → 1.0s (was causing 5 main-thread redraws/sec during scroll)
  • landingSnapshots, cardioLoadWeeklyPoints, healthMonitorSnapshots cached in @State on Home and Health views; recomputed only on data change, not every scroll frame

apurv-1 added 5 commits June 3, 2026 09:51
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.
@apurv-1

apurv-1 commented Jun 3, 2026

Copy link
Copy Markdown
Author

@b-nnett check this out, also DM'd you on X.

goamorim added a commit to goamorim/goose that referenced this pull request Jun 3, 2026
goamorim added a commit to goamorim/goose that referenced this pull request Jun 3, 2026
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).
tigercraft4 referenced this pull request in tigercraft4/goose Jun 3, 2026
tigercraft4 referenced this pull request in tigercraft4/goose Jun 3, 2026
…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?

@OKKHALIL3 OKKHALIL3 Jun 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 tigercraft4 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@tigercraft4 tigercraft4 Jun 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded sleep duration→score lookup table in the UI layer.

case 7..<7.5: return 83
case 7.5..<8.5: return 92

This table encodes sleep quality thresholds as magic numbers in Swift. Two problems:

  1. Divergence: the Rust core has its own sleep scoring logic in goose_sleep_v0. When that algorithm is updated these thresholds will not follow.
  2. 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 }

@tigercraft4 tigercraft4 Jun 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_v0 is 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_ID versioning contract means nothing if there is a parallel implementation.

The fix: do not compute recovery in Swift. Instead:

  1. Ingest HK-derived HRV, RHR, and HR samples into the bridge: metrics.hrv_features, metrics.resting_hr_features.
  2. Call metrics.recovery_score_from_features with those inputs.
  3. 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())

@tigercraft4 tigercraft4 Jun 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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 the official_whoop_label source-identity guard in store.rs — HK-derived metrics must never trigger or spoof that guard.
  2. 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.

tigercraft4 referenced this pull request in tigercraft4/goose Jun 5, 2026
tigercraft4 referenced this pull request in tigercraft4/goose Jun 5, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants