A local-only iOS 17+ app that reads Apple Health data via HealthKit and computes four daily scores — Recovery, Strain, Sleep, and Stress — displayed on a modern SwiftUI dark-mode dashboard.
No backend. No accounts. No network calls. Everything runs on-device.
| Score | Range | What it measures |
|---|---|---|
| Recovery | 0–100 | Readiness to perform: HRV, resting HR, sleep quality, and prior strain |
| Strain | 0–100 | Cardiovascular load relative to personal capacity (heart-rate zone model) |
| Sleep | 0–100 | 5-component quality score: duration, stages, sleeping HRV/HR, interruptions |
| Stress | 0–100 | Autonomic stress estimated from daytime HRV and heart rate |
Each score has a 5-tier category system displayed consistently across charts and insight cards:
| Tier | Recovery | Strain | Sleep | Stress | Color |
|---|---|---|---|---|---|
| Excellent | 85–100 | — | 90–100 | 0–20 | Green |
| Good | 70–84 | 0–19 | 75–89 | 21–40 | Light Green |
| Fair/Light | 50–69 | 20–39 | 60–74 | 41–60 | Yellow |
| Low/Moderate | 30–49 | 40–59 | 40–59 | 61–80 | Orange |
| Very Low | 0–29 | 60–79 | 0–39 | 81–100 | Red |
Strain is calculated using a cardiovascular zone-load model.
Step 1 — HR Zone Classification (MaxHR % thresholds):
| Zone | % of Max HR | Weight | Notes |
|---|---|---|---|
| Z1 | 50–60% | 0 | Recovery intensity — no strain contribution |
| Z2 | 60–70% | 1 | Fat burn / aerobic base |
| Z3 | 70–80% | 2 | Aerobic |
| Z4 | 80–90% | 3 | Anaerobic threshold |
| Z5 | 90–100% | 4 | Max effort |
HR below 50% of MaxHR is passive physiology (resting/sleeping) and is excluded entirely.
Step 2 — StrainLoad (raw zone-weighted minutes):
StrainLoad = Σ (minutes in zone × zone weight)
Handling HealthKit sampling gaps: Apple Watch HR samples are sparse outside workouts (every 5–10 minutes at rest vs every 5 seconds during exercise). A 10-minute gap between two passive readings must not be interpreted as 10 minutes of cardiovascular effort. Each inter-sample interval is therefore capped at 1 minute:
minutes = min(rawMinutes, 1.0)
This means a 10-minute gap between resting readings contributes the same as a 1-minute gap — just 1 minute at whatever zone the average HR maps to.
Step 3 — Personal Capacity (rolling 14-day average of StrainLoad):
- During the first 7 days (calibration): capacity defaults to 500
- After 7 days:
Capacity = average(last 14 days of StrainLoad)
Step 4 — StrainScore:
StrainScore = min(100, StrainLoad / Capacity × 100)
Expected output ranges:
| Activity level | Typical StrainScore |
|---|---|
| Light day | 10–30 |
| Moderate activity | 30–60 |
| Hard training | 60–85 |
| Elite training | 85–100 |
Workout-aware calculation: The algorithm iterates the full HR sample timeline in order and tags each consecutive pair as workout or incidental based on whether the pair's midpoint falls within a recorded workout window. This preserves time continuity — filtering samples before calculating would break adjacent-pair intervals and inflate load.
Debug logging (DEBUG builds only): StrainCalculator.calculate() prints per-zone minute counts, StrainLoad, and the resulting score to the console for algorithm verification:
[StrainCalculator] Zone minutes — Z1: 22.0 Z2: 14.0 Z3: 8.0 Z4: 5.0 Z5: 0.0 | StrainLoad: 46.0
| Component | Weight | Calculation |
|---|---|---|
| Duration | 30% | min(100, T/N × 100) — T = total sleep, N = personalised sleep need |
| Stage mix | 30% | 0.40×deep + 0.40×REM + 0.20×core vs optimal ratios (20% / 22% / 50%) |
| Sleeping HRV | 15% | Ratio vs 30-day HRV baseline; higher = better |
| Sleeping HR | 15% | Ratio vs baseline; lower = better |
| Interruptions | 10% | max(0, 100 − awake_segments × 15) |
Sleep Need: baseline sleep goal + sleep debt (3-day rolling, capped at 2 h/night) + strain factor (up to +1 h at max strain).
Recovery = 0.40 × HRV_score + 0.25 × RHR_score + 0.25 × Sleep_score + 0.10 × Strain_recovery
Apple Health (HealthKit)
└── HealthKitManager (HealthDataProviding protocol)
└── DashboardViewModel
├── Calculators (pure functions, no state)
│ ├── StrainCalculator → StrainLoad + StrainScore
│ ├── SleepCalculator → SleepScore + SleepNeed
│ ├── RecoveryCalculator → RecoveryScore
│ └── StressCalculator → StressScore
├── MetricsStore → UserDefaults (90-day window)
└── InsightCache.invalidatePhysio()
CheckInStore (UserDefaults + Codable)
├── CheckInViewModel
│ ├── CheckInStore.save()
│ ├── HealthKitManager.writeBehavioralData()
│ └── InsightCache.invalidateBehavior()
├── DashboardViewModel → BehaviorEngine.coachingTips()
└── InsightsViewModel → BehaviorEngine.generateInsights()
InsightCache (UserDefaults + Codable)
├── InsightsViewModel.generateInsights(forceRefresh:)
└── BackgroundTaskManager → BGTaskScheduler
Insights are computed once and cached. They are never recomputed on every app launch.
Cache-first loading (InsightsViewModel):
- Check staleness for physio cache and behavior cache independently
- If fresh: load cached
[Insight]/[BehaviorInsight]from UserDefaults — instant, zero compute - If stale: recompute, save to cache, update UI
Staleness rules:
| Cache | Stale when |
|---|---|
| Physio | No cache exists, OR cache is from a previous calendar day, OR new metrics were saved after the cache was built |
| Behavior | No cache exists, OR a new check-in was submitted after the cache was built |
Cache invalidation triggers:
DashboardViewModel.fetchAllMetrics()→InsightCache.shared.invalidatePhysio()after saving metricsCheckInViewModel.save()→InsightCache.shared.invalidateBehavior()after saving check-inSettingsViewReset Baselines →InsightCache.shared.invalidateAll()
UserDefaults keys (versioned with _v2 suffix to prevent reading stale formats):
cachedPhysioInsights_v2— encoded[Insight]cachedBehaviorInsights_v2— encoded[BehaviorInsight]physioInsightsCachedAt_v2— build timestamp (Date)behaviorInsightsCachedAt_v2— build timestamp (Date)
All persistence uses UserDefaults + Codable. No CoreData, SQLite, or network storage.
| Store | Key prefix | Retention | Contents |
|---|---|---|---|
MetricsStore |
dailyMetrics_ |
90 days | DailyMetrics snapshots |
CheckInStore |
checkIn_ |
Indefinite | DailyCheckIn records |
NotificationStore |
notifications |
14 days | NotificationRecord history |
InsightCache |
cached*_v2 |
Until stale | [Insight], [BehaviorInsight] |
Each day's metrics are keyed by a normalized date string (yyyy-MM-dd). The stores prune old entries automatically on write to stay within their retention windows.
Raw HealthKit data is never stored locally. Only computed scores and metadata are persisted.
BackgroundTaskManager wraps BGTaskScheduler to refresh the insight cache while the app is in the background.
Task identifier: com.soma.insight-refresh
Minimum interval: 1 hour
Registration: BackgroundTaskManager.shared.registerTasks() — called in SomaApp.init() before the first runloop tick.
Scheduling: BackgroundTaskManager.shared.scheduleInsightRefresh() — called when the main tab view appears and after onboarding completes. Rescheduled automatically after each background run.
Background execution flow:
- System wakes the app in the background
handleInsightRefresh(task:)fires- Immediately reschedules the next background window
- Instantiates
InsightsViewModelwith live stores and callsgenerateInsights(forceRefresh: true) - Marks task complete (or failed if the expiration handler fires first)
- 2×2 metric grid with colour-coded scores and 7-day sparklines
- Pull-to-refresh with 5-minute debounce
- "Building baseline" banner displayed until ≥ 14 days of HRV history exist
- How to Improve Today card — personalised coaching bullets derived from your behavioral patterns and today's physiology
- Daily check-in prompt (shown until today's check-in is complete)
- Quick Stats row: steps, active calories, sleep duration, VO2 max
Switch between 7 / 30 / 90-day views with Swift Charts line charts for all four scores plus HRV and resting HR history. Each data point includes a tooltip showing: Mar 15 · 60 — Fair. An Ayurvedic Sleep score trend is included with its own detail screen.
Today — physiological insight cards surfaced from today's data (low HRV, high strain, sleep debt, elevated stress, etc.), each showing the date it was generated.
Your Patterns — behavioral intelligence cards showing which of your logged behaviors are statistically correlated with better or worse next-day outcomes, with effect size in real units (ms, bpm, or points) and observation count.
A dedicated tab showing the last 14 days of recovery notifications grouped by date, so you can review trends over time without leaving the app.
A quick card-based flow (under 10 seconds) to log the previous day's behaviors:
| Category | What you log |
|---|---|
| Alcohol | Consumed (yes/no) + units: none / 1–2 / 3–4 / 5+ |
| Stimulants | Caffeine after 5 PM |
| Nutrition timing | Late meal within 2 h of bed |
| Pre-sleep habits | Screen use 1 h before bed, workout within 2 h of bed |
| Stress | Subjective level 1–5 |
| Recovery practices | Meditation, stretching, cold exposure |
Check-in data is persisted locally via CheckInStore (UserDefaults + Codable). When HealthKit write permission is granted, alcohol (grams) and caffeine (mg) are also written to Apple Health.
BehaviorEngine correlates each behavior with five next-day physiological metrics:
- Sleep Score
- Recovery Score
- HRV (average)
- Sleeping HRV
- Sleeping HR
Rules before a correlation is shown:
- At least 5 observations in both "with behavior" and "without behavior" groups
- Absolute mean difference ≥ 2.0 (points / ms / bpm)
Insights are sorted by effect magnitude. Both harmful behaviors (alcohol, late caffeine, screen time) and beneficial behaviors (meditation, stretching, cold exposure) are detected.
- Open
Soma.xcodeprojin Xcode 15+. - Select your development team under Signing & Capabilities → Team.
- Select the Soma target in the project navigator.
- Go to Signing & Capabilities → + Capability → HealthKit.
- Ensure "Clinical Health Records" is unchecked (not needed).
- Select the Soma target in the project navigator.
- Go to Signing & Capabilities → + Capability → Background Modes.
- Check Background fetch and Background processing.
- Connect iPhone via USB or use wireless debugging.
- Select the device in the Xcode toolbar.
- Press Cmd+R to build and run.
- Trust the developer certificate: Settings → General → VPN & Device Management.
- On first launch, tap "Allow Health Access" when prompted.
- Enable all data types in the Health permissions sheet.
- If previously denied: Settings → Health → Data Access → Soma → enable all.
Soma/
├── SomaApp.swift # App entry point; registers BGTaskScheduler
├── Models/
│ ├── DailyMetrics.swift # Core daily snapshot model (includes strainLoad)
│ ├── SleepData.swift # Sleep stages + window + interruption count
│ ├── HeartRateZone.swift # HR zone enum (1–5), MaxHR % thresholds
│ └── DailyCheckIn.swift # Behavior check-in model (Codable)
├── Services/
│ ├── HealthKitManager.swift # All HealthKit read/write + HealthDataProviding protocol
│ ├── MetricsStore.swift # Daily metrics persistence (UserDefaults, 90 days)
│ ├── CheckInStore.swift # Check-in persistence (UserDefaults + Codable)
│ ├── NotificationStore.swift # Notification history persistence (14 days)
│ ├── NotificationScheduler.swift # UNUserNotificationCenter + NotificationStore write
│ ├── InsightCache.swift # Insight cache (UserDefaults, staleness logic)
│ └── BackgroundTaskManager.swift # BGTaskScheduler registration and scheduling
├── Calculators/
│ ├── BaselineCalculator.swift # Rolling 30-day baselines, normalise, clamp, extractHistory
│ ├── RecoveryCalculator.swift # Recovery score 0–100 (40/25/25/10 weights)
│ ├── StrainCalculator.swift # Zone-load model: StrainLoad, capacity, StrainScore 0–100
│ ├── SleepCalculator.swift # Sleep score 0–100 (5 components) + sleep need
│ ├── StressCalculator.swift # Stress 0–100 (daytime HRV + HR)
│ ├── AyurvedicSleepCalculator.swift # Dosha-aware sleep quality scoring
│ └── BehaviorEngine.swift # Behavior–outcome correlations + coaching tips
├── ViewModels/
│ ├── DashboardViewModel.swift # Today's metrics, sparklines, coaching tips, bedtime
│ ├── TrendsViewModel.swift # Chart data for 7/30/90-day trends
│ ├── InsightsViewModel.swift # Physiological + behavioral insight cards (cache-first)
│ ├── CheckInViewModel.swift # Draft check-in + save + HealthKit write + cache invalidation
│ └── DayDetailViewModel.swift # Day-level drill-down data formatting
└── Views/
├── MainTabView.swift # TabView (Dashboard / Trends / Insights / Notifications)
├── DashboardView.swift # 2×2 grid, check-in prompt, coaching card
├── TrendsView.swift # Swift Charts line charts with date+category tooltips
├── InsightsView.swift # Today + Your Patterns sections with date badges
├── NotificationsView.swift # 14-day notification history grouped by date
├── OnboardingView.swift # Welcome + HealthKit permission flow
├── SettingsView.swift # Age, max HR, baseline sleep, clear data
├── CheckInView.swift # Card-based check-in sheet
├── MetricDetailView.swift # Per-metric trend chart + insights panel
├── AyurvedicSleepDetailView.swift # Ayurvedic sleep score breakdown + history chart
└── Components/
├── MetricCardView.swift # Score card with ring + sparkline (no "/100")
├── SparklineView.swift # 7-day mini chart
├── RingView.swift # Circular progress arc
└── ColorState.swift # 5-tier color/category system + Color(hex:)
SomaTests/
├── BaselineCalculatorTests.swift
├── RecoveryCalculatorTests.swift
├── StrainCalculatorTests.swift # Zone load, score, capacity model
├── WorkoutStrainTests.swift # Workout-aware strain calculation
├── SleepCalculatorTests.swift
├── StressCalculatorTests.swift
└── BehaviorEngineTests.swift
In Xcode: Product → Test (Cmd+U)
| Test file | Coverage |
|---|---|
SleepCalculatorTests |
5-component score, sleeping HRV/HR sub-scores, interruption score, sleep need, sleep debt |
RecoveryCalculatorTests |
40/25/25/10 weights, nil baseline fallback, green-range assertion, training recommendation strings |
StrainCalculatorTests |
Zone-time accumulation, HR zone classification, load/score/capacity model |
WorkoutStrainTests |
Workout-aware strain, incidental vs workout load split |
StressCalculatorTests |
Daytime HRV/HR stress model, boundary conditions |
BaselineCalculatorTests |
Rolling mean, normalisation, clamp, extractHistory key-path extraction |
BehaviorEngineTests |
Insight generation with synthetic data, delta filtering (< 2.0 suppressed), impactDescription text content, coaching tip fallback, max-3-tip limit |
Heart rate samples, HRV (SDNN), resting heart rate, respiratory rate, VO2 max, active energy burned, step count, sleep analysis (stages + timing), sleep goal.
Dietary alcohol (grams, derived as 14 g/drink), dietary caffeine (mg, 200 mg per logged serving). Requires user consent. Written with source "Soma".
- Xcode 15+
- iOS 17.0+ deployment target
- Swift 5.9
- iPhone with Apple Watch for real HRV/sleep data — or use the
HealthDataProvidingprotocol to inject mock data for development - HealthKit capability enabled on the Soma target
- Background Modes capability (Background fetch + Background processing) for background insight refresh