Skip to content

HealthKit: Sync sleep schedule + export sleep stages to Apple Health #35

@ng

Description

@ng

Context

Users currently must manually type bedtime/wake time in the schedule UI. Apple Health's Sleep Schedule (set in Health app → Sleep) already stores this — we should read it. Separately, the app classifies sleep stages (wake/light/deep/REM) via SleepAnalyzer but the results stay local. Writing them to HealthKit lets the data appear in Apple's Health app Sleep section.

Important limitation: Apple Health has no native sleep score type. There is no HKQuantityTypeIdentifier for a numeric sleep quality score. We can only write sleep stage samples (HKCategorySample with .sleepAnalysis). Apple's Health app will display the stages and compute its own summary from what we provide. Our 0-100 quality score stays app-local only.

Feature 1: Read Sleep Schedule from Apple Health

Read the user's bedtime/wake time from Apple Health instead of forcing manual entry.

  • Apple's Sleep Schedule creates future-dated .inBed samples in HealthKit
  • Existing partial implementation in SmartCurveView.swift (lines 470-575) already reads these — extract into reusable service
  • Manual button: Always-visible "Sync from Health" button in SleepTimeCardView
  • Auto-sync toggle: When enabled in settings, auto-pulls schedule every time Schedule tab loads
  • Returns HH:mm strings matching PowerSchedule.on / AlarmSchedule.time format
  • Falls back to 7-day historical sleep data if no future schedule exists

Feature 2: Export Sleep Stages to Apple Health

After SleepAnalyzer classifies a night's sleep, automatically write the results to HealthKit.

Stage mapping:

App Stage HealthKit Value
.wake HKCategoryValueSleepAnalysis.awake
.light .asleepCore
.deep .asleepDeep
.rem .asleepREM
overall session .inBed (enteredBedDate → leftBedDate)

Write behavior:

  • Automatic after analysis completes (when enabled in settings)
  • Merge consecutive same-stage epochs into contiguous HKCategorySample blocks (not 60s micro-samples)
  • Deduplication: check last-written record ID (UserDefaults) + query HealthKit for overlapping samples before writing
  • No-op in demo mode

Implementation Plan

Step 1: Add Log.health category

File: Services/Log.swift

static let health = Logger(subsystem: "com.sleepypod.ios", category: "health")

Step 2: Create Services/HealthKitService.swift

New @MainActor @Observable class (matches existing service patterns).

Authorization: Request both read+write for HKCategoryType(.sleepAnalysis). Triggered lazily on first use.

ReadfetchSleepSchedule() async -> (bedtime: String, wake: String)?

  • Queries future-dated .inBed samples
  • Falls back to 7-day history
  • Returns HH:mm strings

WritewriteSleepData(epochs:sleepRecord:) async throws

  • Takes [SleepAnalyzer.SleepEpoch] + SleepRecord
  • Writes one .inBed sample spanning full session
  • Merges consecutive same-stage epochs into contiguous HKCategorySample blocks
  • Batch-saves via HKHealthStore.save([HKObject])

Deduplication: UserDefaults fast-path (last record ID) + HealthKit overlap query.

Preferences (UserDefaults-backed):

  • healthKitSyncScheduleEnabled: Bool
  • healthKitWriteSleepEnabled: Bool

Step 3: Wire into app environment

File: SleepypodApp.swift

  • Add @State private var healthKitService = HealthKitService()
  • Add .environment(healthKitService) to chain

Step 4: Refactor SmartCurveView

File: Views/Schedule/SmartCurveView.swift

  • Remove importFromHealth(), queryScheduleSamples(), applyTimes() (~100 lines of dead HealthKit code)
  • Remove healthSynced/healthError state vars

Step 5: Schedule sync UI

File: Views/Schedule/SleepTimeCardView.swift

  • Add "Sync from Health" button (heart.text.clipboard icon) below bedtime/wake row
  • On tap: fetchSleepSchedule() → update PowerSchedule.on + AlarmSchedule.time
  • Auto-sync via .task modifier when toggle is enabled

Step 6: Auto-write sleep stages

File: Views/Data/HealthScreen.swift (after sleepAnalyzer.analyze())

if healthKitService.healthKitWriteSleepEnabled,
   !sleepAnalyzer.stages.isEmpty,
   let record = metricsManager.selectedDayRecord {
    try? await healthKitService.writeSleepData(
        epochs: sleepAnalyzer.stages, sleepRecord: record
    )
}

Step 7: Settings toggles

File: Views/Settings/DeviceSettingsCardView.swift
Add "Apple Health" section:

  • "Sync Sleep Schedule" toggle → healthKitSyncScheduleEnabled
  • "Export Sleep Stages" toggle → healthKitWriteSleepEnabled
  • First enable triggers requestAuthorization(); revert if denied

Step 8: Tests

File: SleepypodTests/HealthKitServiceTests.swift

  • Epoch merging (consecutive same-stage → single block)
  • Stage mapping correctness
  • HH:mm formatting from Date components

Files Summary

Action File
Create Services/HealthKitService.swift
Create SleepypodTests/HealthKitServiceTests.swift
Modify Services/Log.swift — add health category
Modify SleepypodApp.swift — add to environment
Modify Views/Schedule/SmartCurveView.swift — remove ~100 lines dead HealthKit code
Modify Views/Schedule/SleepTimeCardView.swift — add sync button + auto-sync
Modify Views/Data/HealthScreen.swift — add write after analysis
Modify Views/Settings/DeviceSettingsCardView.swift — add toggles

Already configured (no changes needed): Sleepypod.entitlements (HealthKit enabled), Info.plist (read/write usage descriptions)

Edge Cases & Notes

  • requestAuthorization returns success: true even when user denies specific types (Apple privacy design). Must handle errors on actual read/write, not rely on auth callback.
  • If user has never set a Sleep Schedule in Health, no future-dated samples exist. Fallback to historical data handles this.
  • Write timing: happens when user views Health tab (retrospective analysis needs full night's data). Background processing is a future enhancement.
  • Deduplication prevents duplicates when viewing the same night multiple times.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions