Skip to main content

HealthSync source code

info

This page contains the complete source code for the HealthSync iOS app. For setup instructions, architecture, and the Apple Watch companion app, see the main HealthSync page.

Part 3: Swift code files

Create these files in your Xcode project. Right-click on the HealthSync folder in the navigator and select New File > Swift File.

GroupFilePurpose
ModelsHealthModels.swiftData models for API payloads, SleepTracker enum, SleepPreferences struct
ManagersHealthKitManager.swiftHealthKit auth, queries, session-based sleep grouping, source filtering
ManagersAPIClient.swiftDashboard API communication
ManagersNetworkMonitor.swiftWiFi detection for background sync gating
ViewsSettingsView.swiftAPI configuration UI with Sleep Preferences (tracker picker, threshold)
(root)ContentView.swiftMain sync status and controls
(root)HealthSyncApp.swiftApp entry point with background task setup

3.1 Data models (Models/HealthModels.swift)

Create a Models group and add HealthModels.swift:

//
// HealthModels.swift
// HealthSync
//
// Data models for health records sent to the dashboard API
//

import Foundation

// MARK: - Sleep Record

struct SleepRecord: Codable, Identifiable {
var id: String { date }

let date: String // "2026-02-04"
let sleepStart: String // "23:45"
let sleepEnd: String // "07:15"
let timeAsleepMins: Int
let remMins: Int
let deepMins: Int
let coreMins: Int
let efficiency: Int // 0-100
let wakeCount: Int
let source: String // "Apple Watch"
}

// MARK: - Daily Metrics

struct DailyMetrics: Codable, Identifiable {
var id: String { date }

let date: String // "2026-02-04"
let steps: Int
let activeEnergy: Int // kcal
let restingHr: Int? // bpm
let hrv: Int? // ms
}

// MARK: - Workout Record

struct WorkoutRecord: Codable, Identifiable {
var id: String { "\(date)-\(startTime)-\(type)" }

let date: String // "2026-02-04"
let startTime: String // "07:00"
let type: String // "Running"
let durationMins: Int
let calories: Int
let avgHr: Int?
let distanceKm: Double? // from HKWorkout.totalDistance (running, cycling, walking)
}

// MARK: - Sleep Tracker

/// Authoritative sleep data source. Filters HealthKit queries to a single source
/// by bundle ID pattern, preventing multi-source data corruption.
enum SleepTracker: String, Codable, CaseIterable {
case oura = "oura"
case appleWatch = "appleWatch"
case iPhone = "iPhone"
case autoSleep = "autoSleep"

var displayName: String { /* ... */ }
var bundleIdPattern: String {
switch self {
case .oura: return "com.ouraring"
case .appleWatch: return "com.apple.health"
case .iPhone: return "com.apple.health"
case .autoSleep: return "com.tantsissa.AutoSleep"
}
}
}

/// User preferences for sleep data processing
struct SleepPreferences: Codable {
var selectedTracker: SleepTracker
var thresholdHours: Double // Default 7.5
}

// MARK: - API Payload

struct HealthPayload: Codable {
let sleep: [SleepRecord]
let metrics: [DailyMetrics]
let workouts: [WorkoutRecord]
}

// MARK: - API Response

struct ImportResponse: Codable {
let imported: ImportCounts
}

struct ImportCounts: Codable {
let sleep: Int
let metrics: Int
let workouts: Int
}

// MARK: - Sync State

struct SyncState: Codable {
var lastSleepSync: Date?
var lastMetricsSync: Date?
var lastWorkoutSync: Date?
var lastSuccessfulUpload: Date?
var uploadCount: Int = 0
var errorCount: Int = 0
var lastError: String?
}

3.2 HealthKit manager (Managers/HealthKitManager.swift)

Create a Managers group and add HealthKitManager.swift:

//
// HealthKitManager.swift
// HealthSync
//
// Handles HealthKit authorization, queries, and background delivery
//

import Foundation
import HealthKit
import os.log

@Observable
class HealthKitManager {

// MARK: - Singleton

/// Shared instance — observer queries and background delivery registrations
/// must survive the full app lifecycle (including background wakes).
/// Creating throwaway instances loses the running HKObserverQuery handles.
static let shared = HealthKitManager()

// MARK: - Properties

private let store = HKHealthStore()
private let logger = Logger(subsystem: "com.yourname.HealthSync", category: "HealthKit")

/// Tracks whether observer queries have already been registered on this
/// singleton to avoid duplicate registrations across foreground/background.
private var observersRegistered = false

var isAuthorized = false
var authorizationError: String?

// Data types we want to read
private let readTypes: Set<HKObjectType> = {
var types = Set<HKObjectType>()

// Sleep
if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
types.insert(sleepType)
}

// Workouts
types.insert(HKObjectType.workoutType())

// Quantity types
let quantityTypes: [HKQuantityTypeIdentifier] = [
.stepCount,
.activeEnergyBurned,
.restingHeartRate,
.heartRateVariabilitySDNN
]

for identifier in quantityTypes {
if let type = HKObjectType.quantityType(forIdentifier: identifier) {
types.insert(type)
}
}

return types
}()

// MARK: - Initialization

init() {
checkAuthorizationStatus()
}

// MARK: - Authorization

func checkAuthorizationStatus() {
guard HKHealthStore.isHealthDataAvailable() else {
authorizationError = "Health data not available on this device"
isAuthorized = false
return
}

// HealthKit doesn't reveal READ authorization status for privacy.
// We store a flag when user completes authorization flow.
isAuthorized = UserDefaults.standard.bool(forKey: "healthKitAuthorized")
}

func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}

try await store.requestAuthorization(toShare: [], read: readTypes)

await MainActor.run {
isAuthorized = true
authorizationError = nil
UserDefaults.standard.set(true, forKey: "healthKitAuthorized")
}

logger.info("HealthKit authorization completed")
}

// MARK: - Background Delivery

/// Types that should use `.immediate` background delivery frequency.
/// Workouts and sleep are high-value, low-frequency events — we want these ASAP.
private let immediateTypes: Set<HKSampleType> = {
var types = Set<HKSampleType>()
types.insert(HKObjectType.workoutType())
if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
types.insert(sleep)
}
return types
}()

func enableBackgroundDelivery() {
guard HKHealthStore.isHealthDataAvailable() else { return }

for type in readTypes {
guard let sampleType = type as? HKSampleType else { continue }

// Use .immediate for workouts and sleep; .hourly for high-frequency types
// (steps, energy) which have an iOS-imposed hourly cap anyway.
let frequency: HKUpdateFrequency = immediateTypes.contains(sampleType) ? .immediate : .hourly

store.enableBackgroundDelivery(for: sampleType, frequency: frequency) { success, error in
if let error = error {
self.logger.error("Failed to enable background delivery for \(sampleType.identifier): \(error.localizedDescription)")
} else if success {
self.logger.info("Enabled \(frequency == .immediate ? "immediate" : "hourly") background delivery for \(sampleType.identifier)")
}
}
}
}

func setupObserverQueries(onUpdate: @escaping () -> Void) {
guard HKHealthStore.isHealthDataAvailable() else { return }
guard !observersRegistered else {
logger.info("Observer queries already registered, skipping")
return
}
observersRegistered = true

// Observe sleep changes
if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
let query = HKObserverQuery(sampleType: sleepType, predicate: nil) { [weak self] _, completionHandler, error in
if let error = error {
self?.logger.error("Sleep observer error: \(error.localizedDescription)")
} else {
self?.logger.info("Sleep data changed, triggering sync")
onUpdate()
}
completionHandler() // MUST call this or iOS revokes background privileges
}
store.execute(query)
}

// Observe workout changes
let workoutQuery = HKObserverQuery(sampleType: HKObjectType.workoutType(), predicate: nil) { [weak self] _, completionHandler, error in
if let error = error {
self?.logger.error("Workout observer error: \(error.localizedDescription)")
} else {
self?.logger.info("Workout data changed, triggering sync")
onUpdate()
}
completionHandler()
}
store.execute(workoutQuery)

// Observe step count changes
if let stepType = HKObjectType.quantityType(forIdentifier: .stepCount) {
let stepQuery = HKObserverQuery(sampleType: stepType, predicate: nil) { [weak self] _, completionHandler, error in
if let error = error {
self?.logger.error("Steps observer error: \(error.localizedDescription)")
} else {
self?.logger.info("Step data changed, triggering sync")
onUpdate()
}
completionHandler()
}
store.execute(stepQuery)
}

// Observe active energy changes
if let energyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned) {
let energyQuery = HKObserverQuery(sampleType: energyType, predicate: nil) { [weak self] _, completionHandler, error in
if let error = error {
self?.logger.error("Energy observer error: \(error.localizedDescription)")
} else {
self?.logger.info("Active energy changed, triggering sync")
onUpdate()
}
completionHandler()
}
store.execute(energyQuery)
}

logger.info("Observer queries set up for sleep, workouts, steps, and active energy")
}

// MARK: - Fetch Sleep Data

func fetchSleepData(since: Date) async throws -> [SleepRecord] {
guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
return []
}

let predicate = HKQuery.predicateForSamples(withStart: since, end: Date(), options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)

return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: sleepType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [sortDescriptor]
) { _, samples, error in
if let error = error {
continuation.resume(throwing: error)
return
}

guard let samples = samples as? [HKCategorySample] else {
continuation.resume(returning: [])
return
}

let records = self.processSleepSamples(samples)
continuation.resume(returning: records)
}
store.execute(query)
}
}

/// Group HealthKit sleep samples into sessions based on temporal proximity.
/// Samples within `gapThreshold` seconds of each other belong to the same session.
/// A gap larger than the threshold starts a new session.
private func groupIntoSessions(_ samples: [HKCategorySample], gapThreshold: TimeInterval = 2 * 3600) -> [[HKCategorySample]] {
let sorted = samples.sorted { $0.startDate < $1.startDate }
var sessions: [[HKCategorySample]] = []
var currentSession: [HKCategorySample] = []

for sample in sorted {
if let lastEnd = currentSession.last?.endDate,
sample.startDate.timeIntervalSince(lastEnd) > gapThreshold {
// Gap exceeds threshold — start a new session
if !currentSession.isEmpty { sessions.append(currentSession) }
currentSession = [sample]
} else {
currentSession.append(sample)
}
}
if !currentSession.isEmpty { sessions.append(currentSession) }
return sessions
}

private func processSleepSamples(_ samples: [HKCategorySample]) -> [SleepRecord] {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"

let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "HH:mm"

// Group samples into sessions by temporal proximity (2-hour gap = new session),
// then assign each session's date based on the latest sample end time (wake-up date).
// This matches the Oura convention and correctly handles post-midnight bedtimes.
let sessions = groupIntoSessions(samples)
var sleepByDate: [String: [HKCategorySample]] = [:]

for session in sessions {
guard let latestEnd = session.map(\.endDate).max() else { continue }
let dateKey = dateFormatter.string(from: latestEnd) // wake-up date
sleepByDate[dateKey, default: []].append(contentsOf: session)
}

var records: [SleepRecord] = []

for (date, daySamples) in sleepByDate {
// HealthKit returns overlapping samples from multiple sources (Apple
// Watch + iPhone) and sometimes overlapping intervals within a single
// source. To get accurate durations, merge overlapping time intervals
// per stage type before summing.

// Track earliest/latest for in-bed calculation
var earliestStart: Date?
var latestEnd: Date?
for sample in daySamples {
if earliestStart == nil || sample.startDate < earliestStart! {
earliestStart = sample.startDate
}
if latestEnd == nil || sample.endDate > latestEnd! {
latestEnd = sample.endDate
}
}

// Group intervals by stage type
var intervalsByStage: [Int: [(start: Date, end: Date)]] = [:]
for sample in daySamples {
intervalsByStage[sample.value, default: []].append((sample.startDate, sample.endDate))
}

// Merge overlapping intervals and sum durations per stage
func mergedMinutes(_ intervals: [(start: Date, end: Date)]) -> Int {
let sorted = intervals.sorted { $0.start < $1.start }
var merged: [(start: Date, end: Date)] = []
for interval in sorted {
if let last = merged.last, interval.start <= last.end {
// Overlapping — extend the current merged interval
merged[merged.count - 1] = (last.start, max(last.end, interval.end))
} else {
merged.append(interval)
}
}
// Accumulate total seconds first, then round once — avoids per-interval
// floor truncation that loses ~13 min/night vs Oura native values.
let totalSeconds = merged.reduce(0.0) { $0 + $1.end.timeIntervalSince($1.start) }
return Int((totalSeconds / 60.0).rounded())
}

let remMins = mergedMinutes(intervalsByStage[HKCategoryValueSleepAnalysis.asleepREM.rawValue] ?? [])
let deepMins = mergedMinutes(intervalsByStage[HKCategoryValueSleepAnalysis.asleepDeep.rawValue] ?? [])
let coreMins = mergedMinutes(intervalsByStage[HKCategoryValueSleepAnalysis.asleepCore.rawValue] ?? [])
let unspecifiedMins = mergedMinutes(intervalsByStage[HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue] ?? [])
let wakeCount = (intervalsByStage[HKCategoryValueSleepAnalysis.awake.rawValue] ?? []).count
let totalAsleep = remMins + deepMins + coreMins + unspecifiedMins

logger.info("Sleep merged \(date): \(daySamples.count) samples -> \(totalAsleep) mins (REM:\(remMins) Deep:\(deepMins) Core:\(coreMins))")

guard let start = earliestStart, let end = latestEnd else { continue }

let inBedMins = Int(end.timeIntervalSince(start) / 60)
let efficiency = inBedMins > 0 ? (totalAsleep * 100) / inBedMins : 0

let record = SleepRecord(
date: date,
sleepStart: timeFormatter.string(from: start),
sleepEnd: timeFormatter.string(from: end),
timeAsleepMins: totalAsleep,
remMins: remMins,
deepMins: deepMins,
coreMins: coreMins,
efficiency: min(efficiency, 100),
wakeCount: wakeCount,
source: daySamples.first?.sourceRevision.source.name ?? "Apple Watch"
)
records.append(record)
}

return records.sorted { $0.date > $1.date }
}

// MARK: - Fetch Daily Metrics

func fetchDailyMetrics(since: Date) async throws -> [DailyMetrics] {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"

var metricsByDate: [String: (steps: Int, energy: Int, hr: Int?, hrv: Int?)] = [:]

// Fetch steps
if let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount) {
let steps = try await fetchDailySum(for: stepType, since: since, unit: .count())
for (date, value) in steps {
metricsByDate[date, default: (0, 0, nil, nil)].steps = Int(value)
}
}

// Fetch active energy
if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) {
let energy = try await fetchDailySum(for: energyType, since: since, unit: .kilocalorie())
for (date, value) in energy {
metricsByDate[date, default: (0, 0, nil, nil)].energy = Int(value)
}
}

// Fetch resting heart rate (average)
if let hrType = HKQuantityType.quantityType(forIdentifier: .restingHeartRate) {
let hr = try await fetchDailyAverage(for: hrType, since: since, unit: HKUnit.count().unitDivided(by: .minute()))
for (date, value) in hr {
metricsByDate[date, default: (0, 0, nil, nil)].hr = Int(value)
}
}

// Fetch HRV (average)
if let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN) {
let hrv = try await fetchDailyAverage(for: hrvType, since: since, unit: .secondUnit(with: .milli))
for (date, value) in hrv {
metricsByDate[date, default: (0, 0, nil, nil)].hrv = Int(value)
}
}

return metricsByDate.map { date, values in
DailyMetrics(
date: date,
steps: values.steps,
activeEnergy: values.energy,
restingHr: values.hr,
hrv: values.hrv
)
}.sorted { $0.date > $1.date }
}

private func fetchDailySum(for type: HKQuantityType, since: Date, unit: HKUnit) async throws -> [String: Double] {
let calendar = Calendar.current
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"

let predicate = HKQuery.predicateForSamples(withStart: since, end: Date(), options: .strictStartDate)

var interval = DateComponents()
interval.day = 1

let anchorDate = calendar.startOfDay(for: since)

return try await withCheckedThrowingContinuation { continuation in
let query = HKStatisticsCollectionQuery(
quantityType: type,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: anchorDate,
intervalComponents: interval
)

query.initialResultsHandler = { _, results, error in
if let error = error {
continuation.resume(throwing: error)
return
}

var dailyValues: [String: Double] = [:]

results?.enumerateStatistics(from: since, to: Date()) { statistics, _ in
if let sum = statistics.sumQuantity() {
let value = sum.doubleValue(for: unit)
let dateKey = dateFormatter.string(from: statistics.startDate)
dailyValues[dateKey] = value
}
}

continuation.resume(returning: dailyValues)
}

store.execute(query)
}
}

private func fetchDailyAverage(for type: HKQuantityType, since: Date, unit: HKUnit) async throws -> [String: Double] {
let calendar = Calendar.current
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"

let predicate = HKQuery.predicateForSamples(withStart: since, end: Date(), options: .strictStartDate)

var interval = DateComponents()
interval.day = 1

let anchorDate = calendar.startOfDay(for: since)

return try await withCheckedThrowingContinuation { continuation in
let query = HKStatisticsCollectionQuery(
quantityType: type,
quantitySamplePredicate: predicate,
options: .discreteAverage,
anchorDate: anchorDate,
intervalComponents: interval
)

query.initialResultsHandler = { _, results, error in
if let error = error {
continuation.resume(throwing: error)
return
}

var dailyValues: [String: Double] = [:]

results?.enumerateStatistics(from: since, to: Date()) { statistics, _ in
if let avg = statistics.averageQuantity() {
let value = avg.doubleValue(for: unit)
let dateKey = dateFormatter.string(from: statistics.startDate)
dailyValues[dateKey] = value
}
}

continuation.resume(returning: dailyValues)
}

store.execute(query)
}
}

// MARK: - Fetch Workouts

func fetchWorkouts(since: Date) async throws -> [WorkoutRecord] {
let predicate = HKQuery.predicateForSamples(withStart: since, end: Date(), options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)

return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: HKObjectType.workoutType(),
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [sortDescriptor]
) { _, samples, error in
if let error = error {
continuation.resume(throwing: error)
return
}

guard let workouts = samples as? [HKWorkout] else {
continuation.resume(returning: [])
return
}

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"

let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "HH:mm"

let records = workouts.map { workout -> WorkoutRecord in
// Get calories from statistics
var calories = 0.0
if let energyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned),
let stats = workout.statistics(for: energyType),
let sum = stats.sumQuantity() {
calories = sum.doubleValue(for: .kilocalorie())
}

// Get average heart rate if available
var avgHr: Int? = nil
if let hrType = HKQuantityType.quantityType(forIdentifier: .heartRate),
let hrStats = workout.statistics(for: hrType),
let avg = hrStats.averageQuantity() {
avgHr = Int(avg.doubleValue(for: HKUnit.count().unitDivided(by: .minute())))
}

return WorkoutRecord(
date: dateFormatter.string(from: workout.startDate),
startTime: timeFormatter.string(from: workout.startDate),
type: workout.workoutActivityType.name,
durationMins: Int(workout.duration / 60),
calories: Int(calories),
avgHr: avgHr
)
}

continuation.resume(returning: records)
}

store.execute(query)
}
}
}

// MARK: - Errors

enum HealthKitError: LocalizedError {
case notAvailable
case authorizationDenied
case queryFailed(String)

var errorDescription: String? {
switch self {
case .notAvailable:
return "Health data is not available on this device"
case .authorizationDenied:
return "Health data access was denied"
case .queryFailed(let message):
return "Query failed: \(message)"
}
}
}

// MARK: - Workout Activity Type Names

extension HKWorkoutActivityType {
var name: String {
switch self {
case .running: return "Running"
case .cycling: return "Cycling"
case .walking: return "Walking"
case .swimming: return "Swimming"
case .hiking: return "Hiking"
case .yoga: return "Yoga"
case .functionalStrengthTraining: return "Strength Training"
case .traditionalStrengthTraining: return "Strength Training"
case .coreTraining: return "Core Training"
case .elliptical: return "Elliptical"
case .rowing: return "Rowing"
case .stairClimbing: return "Stair Climbing"
case .highIntensityIntervalTraining: return "HIIT"
case .pilates: return "Pilates"
case .dance: return "Dance"
case .cooldown: return "Cooldown"
case .crossTraining: return "Cross Training"
default: return "Other"
}
}
}

3.3 API client (Managers/APIClient.swift)

Add APIClient.swift to the Managers group:

//
// APIClient.swift
// HealthSync
//
// Handles API communication with the stats dashboard
//

import Foundation
import os.log

@Observable
class APIClient {

// MARK: - Properties

private let logger = Logger(subsystem: "com.yourname.HealthSync", category: "API") // Replace with your bundle ID

// Using stored properties so @Observable can track changes
var baseURL: String {
didSet { UserDefaults.standard.set(baseURL, forKey: "baseURL") }
}

var apiKey: String {
didSet { UserDefaults.standard.set(apiKey, forKey: "apiKey") }
}

var isConfigured: Bool {
!baseURL.isEmpty && !apiKey.isEmpty
}

var lastUploadDate: Date? {
didSet { UserDefaults.standard.set(lastUploadDate, forKey: "lastUploadDate") }
}

var uploadCount: Int {
didSet { UserDefaults.standard.set(uploadCount, forKey: "uploadCount") }
}

var lastError: String? {
didSet { UserDefaults.standard.set(lastError, forKey: "lastError") }
}

// MARK: - Initialization

init() {
// Load from UserDefaults
self.baseURL = UserDefaults.standard.string(forKey: "baseURL") ?? ""
self.apiKey = UserDefaults.standard.string(forKey: "apiKey") ?? ""
self.lastUploadDate = UserDefaults.standard.object(forKey: "lastUploadDate") as? Date
self.uploadCount = UserDefaults.standard.integer(forKey: "uploadCount")
self.lastError = UserDefaults.standard.string(forKey: "lastError")
}

// MARK: - Upload Health Data

func uploadHealthData(_ payload: HealthPayload) async throws -> ImportResponse {
guard isConfigured else {
throw APIError.notConfigured
}

guard var urlComponents = URLComponents(string: baseURL) else {
throw APIError.invalidURL
}

urlComponents.path = "/api/import/health-app"

guard let url = urlComponents.url else {
throw APIError.invalidURL
}

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("HealthSync-iOS/1.0", forHTTPHeaderField: "User-Agent")
// Reduced timeout: background tasks have limited runtime (~30s total).
// 15s leaves headroom for HealthKit queries + task completion.
request.timeoutInterval = 15

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
request.httpBody = try encoder.encode(payload)

logger.info("Uploading health data: \(payload.sleep.count) sleep, \(payload.metrics.count) metrics, \(payload.workouts.count) workouts")

let (data, response) = try await URLSession.shared.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}

switch httpResponse.statusCode {
case 200...299:
let decoder = JSONDecoder()
let result = try decoder.decode(ImportResponse.self, from: data)

await MainActor.run {
lastUploadDate = Date()
uploadCount += 1
lastError = nil
}

logger.info("Upload successful: imported \(result.imported.sleep) sleep, \(result.imported.metrics) metrics, \(result.imported.workouts) workouts")

return result

case 401:
throw APIError.unauthorized

case 400...499:
let message = String(data: data, encoding: .utf8) ?? "Bad request"
throw APIError.clientError(httpResponse.statusCode, message)

case 500...599:
let message = String(data: data, encoding: .utf8) ?? "Server error"
throw APIError.serverError(httpResponse.statusCode, message)

default:
throw APIError.unexpectedStatus(httpResponse.statusCode)
}
}

// MARK: - Test Connection

func testConnection() async throws -> Bool {
guard isConfigured else {
throw APIError.notConfigured
}

guard var urlComponents = URLComponents(string: baseURL) else {
throw APIError.invalidURL
}

urlComponents.path = "/api/health"

guard let url = urlComponents.url else {
throw APIError.invalidURL
}

var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 10

let (_, response) = try await URLSession.shared.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}

return httpResponse.statusCode == 200
}
}

// MARK: - Errors

enum APIError: LocalizedError {
case notConfigured
case invalidURL
case invalidResponse
case unauthorized
case clientError(Int, String)
case serverError(Int, String)
case unexpectedStatus(Int)
case uploadFailed(String)

var errorDescription: String? {
switch self {
case .notConfigured:
return "API not configured. Set URL and API key in Settings."
case .invalidURL:
return "Invalid API URL"
case .invalidResponse:
return "Invalid response from server"
case .unauthorized:
return "Unauthorized. Check your API key."
case .clientError(let code, let message):
return "Client error (\(code)): \(message)"
case .serverError(let code, let message):
return "Server error (\(code)): \(message)"
case .unexpectedStatus(let code):
return "Unexpected status code: \(code)"
case .uploadFailed(let message):
return "Upload failed: \(message)"
}
}
}

3.4 Settings view (Views/SettingsView.swift)

Create a Views group and add SettingsView.swift:

//
// SettingsView.swift
// HealthSync
//
// Settings for API configuration
//

import SwiftUI

struct SettingsView: View {
@Environment(\.dismiss) private var dismiss
@Bindable var apiClient: APIClient

@State private var baseURL: String = ""
@State private var apiKey: String = ""
@State private var isTesting = false
@State private var testResult: TestResult?

enum TestResult {
case success
case failure(String)
}

var body: some View {
NavigationStack {
Form {
Section {
TextField("Dashboard URL", text: $baseURL)
.textContentType(.URL)
.keyboardType(.URL)
.autocapitalization(.none)
.autocorrectionDisabled()

SecureField("API Key", text: $apiKey)
.textContentType(.password)
.autocapitalization(.none)
.autocorrectionDisabled()
} header: {
Text("API Configuration")
} footer: {
Text("Enter your stats dashboard URL (e.g., https://stats.example.com) and the DAEMON_API_KEY from your Kubernetes secret.")
}

Section {
Button {
Task {
await testConnection()
}
} label: {
HStack {
Text("Test Connection")
Spacer()
if isTesting {
ProgressView()
} else if let result = testResult {
switch result {
case .success:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
case .failure:
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
}
}
}
}
.disabled(baseURL.isEmpty || isTesting)

if case .failure(let message) = testResult {
Text(message)
.font(.caption)
.foregroundStyle(.red)
}
}

Section {
LabeledContent("Total Uploads", value: "\(apiClient.uploadCount)")

if let lastUpload = apiClient.lastUploadDate {
LabeledContent("Last Upload", value: lastUpload.formatted())
}

if let error = apiClient.lastError {
LabeledContent("Last Error") {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
}
} header: {
Text("Statistics")
}

Section {
Button("Reset All Settings", role: .destructive) {
resetSettings()
}
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
saveSettings()
dismiss()
}
}
}
.onAppear {
baseURL = apiClient.baseURL
apiKey = apiClient.apiKey
}
}
}

private func testConnection() async {
isTesting = true
testResult = nil

// Temporarily save for testing
apiClient.baseURL = baseURL
apiClient.apiKey = apiKey

do {
let success = try await apiClient.testConnection()
testResult = success ? .success : .failure("Connection failed")
} catch {
testResult = .failure(error.localizedDescription)
}

isTesting = false
}

private func saveSettings() {
apiClient.baseURL = baseURL
apiClient.apiKey = apiKey
}

private func resetSettings() {
baseURL = ""
apiKey = ""
apiClient.baseURL = ""
apiClient.apiKey = ""
UserDefaults.standard.removeObject(forKey: "lastUploadDate")
UserDefaults.standard.removeObject(forKey: "uploadCount")
UserDefaults.standard.removeObject(forKey: "lastError")
testResult = nil
}
}

#Preview {
SettingsView(apiClient: APIClient())
}

3.5 Network monitor (Managers/NetworkMonitor.swift)

Add NetworkMonitor.swift to the Managers group. This gates background syncs on WiFi — essential if your dashboard is only reachable on your local network. Without this, iOS penalises the app for repeated cellular timeouts.

//
// NetworkMonitor.swift
// HealthSync
//
// Monitors network reachability and interface type.
// Used to gate background syncs: we only upload when on WiFi
// since the dashboard is internal-only and unreachable on cellular.
// This prevents iOS from penalising the app for repeated timeouts.
//

import Foundation
import Network
import os.log

final class NetworkMonitor {

static let shared = NetworkMonitor()

private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.yourname.HealthSync.network")
private let logger = Logger(subsystem: "com.yourname.HealthSync", category: "Network")

/// Current network path — updated continuously while monitor is running.
private(set) var currentPath: NWPath?

/// True when the device has an active WiFi connection.
var isOnWiFi: Bool {
guard let path = currentPath else { return false }
return path.status == .satisfied && path.usesInterfaceType(.wifi)
}

/// True when any network is available (WiFi or cellular).
var isConnected: Bool {
currentPath?.status == .satisfied
}

private init() {}

/// Start monitoring. Call once at app launch.
func start() {
monitor.pathUpdateHandler = { [weak self] path in
self?.currentPath = path
self?.logger.info("Network changed: status=\(path.status == .satisfied ? "connected" : "disconnected"), wifi=\(path.usesInterfaceType(.wifi)), cellular=\(path.usesInterfaceType(.cellular))")
}
monitor.start(queue: queue)
logger.info("NetworkMonitor started")
}

/// Stop monitoring (if needed for cleanup).
func stop() {
monitor.cancel()
logger.info("NetworkMonitor stopped")
}
}

3.6 Main content view (ContentView.swift)

Replace the default ContentView.swift.

Important

The ContentView uses HealthKitManager.shared (the singleton) — do NOT create a new @State instance. Background setup (observer queries, background delivery) is handled by HealthSyncApp.init(), not here. This view only manages foreground UI and manual sync.

//
// ContentView.swift
// HealthSync
//
// Main view showing sync status and controls.
// Background setup (observer queries, background delivery) is handled by
// HealthSyncApp.init() — this view only manages foreground UI and manual sync.
//

import SwiftUI

struct ContentView: View {
/// Use the shared singleton so we see the same authorization state
/// and don't create throwaway instances that lose observer queries.
private var healthKitManager = HealthKitManager.shared
@State private var apiClient = APIClient()

@State private var isSyncing = false
@State private var syncResult: SyncResult?
@State private var showingSettings = false
@State private var lookbackDays = 7

enum SyncResult {
case success(ImportResponse)
case failure(String)
}

var body: some View {
NavigationStack {
List {
// Status Section
Section {
StatusRow(
title: "HealthKit",
status: healthKitManager.isAuthorized ? .connected : .disconnected,
detail: healthKitManager.authorizationError
)

StatusRow(
title: "Dashboard API",
status: apiClient.isConfigured ? .connected : .disconnected,
detail: apiClient.isConfigured ? apiClient.baseURL : "Not configured"
)

StatusRow(
title: "Background Sync",
status: healthKitManager.isAuthorized ? .connected : .disconnected,
detail: "Up to 4 updates per hour"
)
} header: {
Text("Status")
}

// Sync Section
Section {
Picker("Lookback Period", selection: $lookbackDays) {
Text("1 day").tag(1)
Text("3 days").tag(3)
Text("7 days").tag(7)
Text("14 days").tag(14)
Text("30 days").tag(30)
}

Button {
Task {
await performSync()
}
} label: {
HStack {
Text("Sync Now")
Spacer()
if isSyncing {
ProgressView()
}
}
}
.disabled(!canSync || isSyncing)

if let result = syncResult {
switch result {
case .success(let response):
VStack(alignment: .leading, spacing: 4) {
Text("Last sync successful")
.foregroundStyle(.green)
Text("Imported: \(response.imported.sleep) sleep, \(response.imported.metrics) metrics, \(response.imported.workouts) workouts")
.font(.caption)
.foregroundStyle(.secondary)
}
case .failure(let message):
Text(message)
.foregroundStyle(.red)
.font(.caption)
}
}
} header: {
Text("Manual Sync")
} footer: {
Text("Background sync happens automatically when health data changes (max 4 times per hour).")
}

// Stats Section
Section {
LabeledContent("Total Uploads", value: "\(apiClient.uploadCount)")

if let lastUpload = apiClient.lastUploadDate {
LabeledContent("Last Upload") {
Text(lastUpload, style: .relative)
}
}
} header: {
Text("Statistics")
}

// Authorization Section
if !healthKitManager.isAuthorized {
Section {
Button("Request Health Access") {
Task {
try? await healthKitManager.requestAuthorization()
if healthKitManager.isAuthorized {
// After first authorization, set up background
// (normally done in App.init but auth wasn't granted yet)
healthKitManager.enableBackgroundDelivery()
healthKitManager.setupObserverQueries {
HealthSyncApp.performBackgroundSync()
}
HealthSyncApp.scheduleBackgroundRefresh()
}
}
}
} header: {
Text("Setup Required")
}
}
}
.navigationTitle("HealthSync")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingSettings = true
} label: {
Image(systemName: "gear")
}
}
}
.sheet(isPresented: $showingSettings) {
SettingsView(apiClient: apiClient)
}
}
}

private var canSync: Bool {
healthKitManager.isAuthorized && apiClient.isConfigured
}

private func performSync() async {
guard canSync else { return }

isSyncing = true
syncResult = nil

do {
let since = Calendar.current.date(byAdding: .day, value: -lookbackDays, to: Date()) ?? Date()

async let sleepData = healthKitManager.fetchSleepData(since: since)
async let metricsData = healthKitManager.fetchDailyMetrics(since: since)
async let workoutsData = healthKitManager.fetchWorkouts(since: since)

let (sleep, metrics, workouts) = try await (sleepData, metricsData, workoutsData)

let payload = HealthPayload(
sleep: sleep,
metrics: metrics,
workouts: workouts
)

let response = try await apiClient.uploadHealthData(payload)
syncResult = .success(response)

} catch {
syncResult = .failure(error.localizedDescription)
apiClient.lastError = error.localizedDescription
}

isSyncing = false
}
}

// MARK: - Status Row

struct StatusRow: View {
let title: String
let status: Status
let detail: String?

enum Status {
case connected
case disconnected
case warning

var color: Color {
switch self {
case .connected: return .green
case .disconnected: return .red
case .warning: return .orange
}
}

var icon: String {
switch self {
case .connected: return "checkmark.circle.fill"
case .disconnected: return "xmark.circle.fill"
case .warning: return "exclamationmark.circle.fill"
}
}
}

var body: some View {
HStack {
Image(systemName: status.icon)
.foregroundStyle(status.color)

VStack(alignment: .leading) {
Text(title)
if let detail = detail {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
}

#Preview {
ContentView()
}

3.7 App entry point (HealthSyncApp.swift)

Replace HealthSyncApp.swift.

Critical

All background setup (observer queries, background delivery, BGTask scheduling) must happen in HealthSyncApp.init(), NOT in ContentView.onAppear. Observer queries set up in a view's onAppear are lost when the app is suspended. The singleton HealthKitManager.shared ensures queries persist across foreground/background transitions.

//
// HealthSyncApp.swift
// HealthSync
//
// Main app entry point with background task registration.
// All background setup (observer queries, background delivery, BGTask scheduling)
// is done here in init() so it runs on EVERY app launch — including background wakes.
//
// Network safety: background syncs are gated on WiFi to prevent iOS penalties
// from repeated cellular timeouts when the dashboard is on a local network.
//

import SwiftUI
import BackgroundTasks
import os.log

@main
struct HealthSyncApp: App {

init() {
// 0. Start network monitor early so WiFi state is available for background decisions
NetworkMonitor.shared.start()

// 1. Register the BGAppRefreshTask handler (must be in init, before app finishes launching)
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.yourname.HealthSync.refresh",
using: nil
) { task in
Self.handleBackgroundRefresh(task: task as! BGAppRefreshTask)
}

// 2. Set up HealthKit background delivery + observer queries on the singleton.
// These MUST be registered early so iOS knows to wake us for health data changes.
let hkm = HealthKitManager.shared
if hkm.isAuthorized {
hkm.enableBackgroundDelivery()
hkm.setupObserverQueries {
Self.performBackgroundSync()
}
}

// 3. Kick off the BGTask chain on first launch
Self.scheduleBackgroundRefresh()

Self.logger.info("HealthSyncApp init complete — background delivery and observers registered")
}

var body: some Scene {
WindowGroup {
ContentView()
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in
// Re-schedule when entering background to keep the chain alive
Self.scheduleBackgroundRefresh()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
// Sync when returning to foreground (e.g. user was on cellular, now on WiFi)
Self.logger.info("App entering foreground — triggering sync")
Self.performForegroundSync()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.significantTimeChangeNotification)) { _ in
// Fires at midnight, timezone changes, daylight saving transitions
Self.logger.info("Significant time change — triggering sync")
Self.performForegroundSync()
}
}
}

// MARK: - Background Tasks (Static)

private static let logger = Logger(subsystem: "com.yourname.HealthSync", category: "App")

// MARK: - Observer Debounce

/// Pending debounced sync work item. Multiple observer callbacks within the
/// debounce window (30s) coalesce into a single upload instead of 4.
private static var pendingSyncWork: DispatchWorkItem?
private static let syncQueue = DispatchQueue(label: "com.yourname.HealthSync.debounce")

static func scheduleBackgroundRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.yourname.HealthSync.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 minutes

do {
try BGTaskScheduler.shared.submit(request)
logger.info("Background refresh scheduled for ~15min from now")
} catch {
logger.error("Failed to schedule background refresh: \(error.localizedDescription)")
}
}

private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
logger.info("Background refresh triggered by BGTaskScheduler")

// Schedule the next refresh immediately (keeps the chain alive)
scheduleBackgroundRefresh()

// WiFi gate: if not on WiFi, complete immediately as success to avoid penalties.
guard NetworkMonitor.shared.isOnWiFi else {
logger.info("Background refresh skipped: not on WiFi (cellular or disconnected)")
task.setTaskCompleted(success: true)
return
}

// Perform the sync with a reduced timeout for background tasks
let syncTask = Task {
await performBackgroundSyncAsync()
}

// Handle task expiration — complete as success to avoid penalty
task.expirationHandler = {
syncTask.cancel()
logger.warning("Background task expired before completing — completing as success to avoid penalty")
}

// Complete the task when sync finishes
Task {
await syncTask.value
task.setTaskCompleted(success: true)
}
}

/// Called from HKObserverQuery callbacks (which fire on a background queue).
/// Debounces multiple observer callbacks within a 30-second window into a single sync.
/// This prevents 4 duplicate uploads when iOS delivers multiple data type changes at once.
/// Also gated on WiFi to prevent cellular timeout penalties.
static func performBackgroundSync() {
syncQueue.async {
pendingSyncWork?.cancel()

// WiFi gate for observer-triggered syncs too
guard NetworkMonitor.shared.isOnWiFi else {
logger.info("Observer sync skipped: not on WiFi")
return
}

logger.info("Observer callback received, debouncing sync (30s window)")
let work = DispatchWorkItem {
Task.detached {
await performBackgroundSyncAsync()
}
}
pendingSyncWork = work
syncQueue.asyncAfter(deadline: .now() + 30, execute: work)
}
}

/// Called when app enters foreground or on significant time change.
/// No debounce — user is actively using the app and likely on WiFi now.
static func performForegroundSync() {
guard NetworkMonitor.shared.isOnWiFi else {
logger.info("Foreground sync skipped: not on WiFi")
return
}

Task.detached {
await performBackgroundSyncAsync()
}
}

/// The actual sync logic — fetches last 3 days of health data and uploads.
private static func performBackgroundSyncAsync() async {
let hkm = HealthKitManager.shared
let apiClient = APIClient()

guard hkm.isAuthorized, apiClient.isConfigured else {
logger.warning("Background sync skipped: not authorized or not configured")
return
}

do {
let since = Calendar.current.date(byAdding: .day, value: -3, to: Date()) ?? Date()

let sleep = try await hkm.fetchSleepData(since: since)
let metrics = try await hkm.fetchDailyMetrics(since: since)
let workouts = try await hkm.fetchWorkouts(since: since)

let payload = HealthPayload(
sleep: sleep,
metrics: metrics,
workouts: workouts
)

let response = try await apiClient.uploadHealthData(payload)
logger.info("Background sync completed: \(response.imported.sleep) sleep, \(response.imported.metrics) metrics, \(response.imported.workouts) workouts")

} catch {
logger.error("Background sync failed: \(error.localizedDescription)")
}
}
}

Part 4: Dashboard API endpoint

The iOS app sends data to /api/import/health-app. Add this endpoint to your stats-dashboard.

4.1 Create the endpoint (app/api/import/health-app/route.ts)

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { prisma } from '@/lib/db';
import { checkRateLimit } from '@/lib/rate-limit';

export const dynamic = 'force-dynamic';

// Input Validation Schemas (iOS App format)
const SleepRecordSchema = z.object({
date: z.string(),
sleepStart: z.string(),
sleepEnd: z.string(),
timeAsleepMins: z.number().int(),
remMins: z.number().int(),
deepMins: z.number().int(),
coreMins: z.number().int(),
efficiency: z.number().int(),
wakeCount: z.number().int(),
source: z.string().default('Apple Watch'),
});

const MetricsRecordSchema = z.object({
date: z.string(),
steps: z.number().int(),
activeEnergy: z.number().int(),
restingHr: z.number().int().nullable().optional(),
hrv: z.number().int().nullable().optional(),
});

const WorkoutRecordSchema = z.object({
date: z.string(),
startTime: z.string(),
type: z.string(),
durationMins: z.number().int(),
calories: z.number().int(),
avgHr: z.number().int().nullable().optional(),
});

const ImportRequestSchema = z.object({
sleep: z.array(SleepRecordSchema).max(100).default([]),
metrics: z.array(MetricsRecordSchema).max(100).default([]),
workouts: z.array(WorkoutRecordSchema).max(500).default([]),
});

function verifyApiKey(request: NextRequest): { valid: boolean; error?: string } {
const expectedKey = process.env.DAEMON_API_KEY;
if (!expectedKey) return { valid: true };

const authHeader = request.headers.get('authorization');
if (!authHeader) return { valid: false, error: 'Missing Authorization header' };
if (!authHeader.startsWith('Bearer ')) return { valid: false, error: 'Invalid format' };

const providedKey = authHeader.substring(7);
return providedKey === expectedKey
? { valid: true }
: { valid: false, error: 'Invalid API key' };
}

function parseDate(dateStr: string): Date {
return new Date(dateStr + 'T00:00:00Z');
}

function calculateTimeInBed(sleepStart: string, sleepEnd: string): number {
const [startH, startM] = sleepStart.split(':').map(Number);
const [endH, endM] = sleepEnd.split(':').map(Number);

const startMins = startH * 60 + startM;
let endMins = endH * 60 + endM;

if (endMins < startMins) endMins += 24 * 60;
return endMins - startMins;
}

export async function POST(request: NextRequest) {
const startTime = Date.now();

// Rate limiting
const clientId = request.headers.get('user-agent') || 'ios-app';
const rateLimit = checkRateLimit(clientId, 30, 60000);
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429 }
);
}

// Authentication
const authResult = verifyApiKey(request);
if (!authResult.valid) {
return NextResponse.json(
{ error: 'Unauthorized', details: authResult.error },
{ status: 401 }
);
}

const importLog = await prisma.importLog.create({
data: { source: 'health-app-ios', status: 'running' },
});

try {
const rawBody = await request.json();
const parseResult = ImportRequestSchema.safeParse(rawBody);

if (!parseResult.success) {
await prisma.importLog.update({
where: { id: importLog.id },
data: { status: 'failed', completedAt: new Date() },
});
return NextResponse.json(
{ error: 'Invalid request', details: parseResult.error.issues },
{ status: 400 }
);
}

const body = parseResult.data;
const results = {
sleep: { imported: 0, errors: 0 },
metrics: { imported: 0, errors: 0 },
workouts: { imported: 0, errors: 0 },
};

// Track whether any data actually changed (for AI insight cache invalidation)
let dataChanged = false;

// Import sleep records
for (const record of body.sleep) {
try {
const date = parseDate(record.date);
const timeInBedMins = calculateTimeInBed(record.sleepStart, record.sleepEnd);
const timeAwakeMins = Math.max(0, timeInBedMins - record.timeAsleepMins);

const existing = await prisma.dailySleep.findUnique({ where: { date } });
const isNew = !existing;
const hasChanges = isNew ||
existing.timeAsleepMins !== record.timeAsleepMins ||
existing.sleepStart !== record.sleepStart ||
existing.sleepEnd !== record.sleepEnd ||
existing.efficiency !== record.efficiency ||
existing.remMins !== record.remMins ||
existing.coreMins !== record.coreMins ||
existing.deepMins !== record.deepMins ||
existing.wakeCount !== record.wakeCount;

await prisma.dailySleep.upsert({
where: { date },
update: {
sleepStart: record.sleepStart,
sleepEnd: record.sleepEnd,
timeInBedMins,
timeAsleepMins: record.timeAsleepMins,
timeAwakeMins,
remMins: record.remMins,
coreMins: record.coreMins,
deepMins: record.deepMins,
wakeCount: record.wakeCount,
efficiency: record.efficiency,
source: record.source,
},
create: {
date,
sleepStart: record.sleepStart,
sleepEnd: record.sleepEnd,
timeInBedMins,
timeAsleepMins: record.timeAsleepMins,
timeAwakeMins,
remMins: record.remMins,
coreMins: record.coreMins,
deepMins: record.deepMins,
wakeCount: record.wakeCount,
efficiency: record.efficiency,
source: record.source,
},
});
results.sleep.imported++;
if (hasChanges) dataChanged = true;
} catch (error) {
console.error('Failed to import sleep record:', error);
results.sleep.errors++;
}
}

// Import metrics records
for (const record of body.metrics) {
try {
const date = parseDate(record.date);

const existingMetric = await prisma.dailyMetrics.findUnique({ where: { date } });
const metricNew = !existingMetric;
const metricChanged = metricNew ||
existingMetric.steps !== record.steps ||
existingMetric.activeEnergy !== record.activeEnergy;

await prisma.dailyMetrics.upsert({
where: { date },
update: {
activeEnergy: record.activeEnergy,
restingEnergy: 0,
restingHr: record.restingHr ?? 0,
hrv: record.hrv ?? null,
steps: record.steps,
},
create: {
date,
activeEnergy: record.activeEnergy,
restingEnergy: 0,
restingHr: record.restingHr ?? 0,
hrv: record.hrv ?? null,
steps: record.steps,
},
});
results.metrics.imported++;
if (metricChanged) dataChanged = true;
} catch (error) {
console.error('Failed to import metrics record:', error);
results.metrics.errors++;
}
}

// Import workouts (unique by date+startTime, iOS is authoritative source)
for (const record of body.workouts) {
try {
const date = parseDate(record.date);
await prisma.workout.upsert({
where: {
date_startTime: {
date,
startTime: record.startTime,
},
},
update: {
type: record.type,
durationMins: record.durationMins,
calories: record.calories,
avgHr: record.avgHr ?? null,
source: 'HealthSync iOS',
},
create: {
date,
startTime: record.startTime,
type: record.type,
durationMins: record.durationMins,
calories: record.calories,
avgHr: record.avgHr ?? null,
source: 'HealthSync iOS',
},
});
results.workouts.imported++;
} catch (error) {
console.error('Failed to import workout record:', error);
results.workouts.errors++;
}
}

// Invalidate AI insight cache only if data actually changed
if (dataChanged) {
await prisma.healthSettings.updateMany({
data: {
cachedInsight: Prisma.DbNull,
cachedInsightAt: null,
},
});
}

const totalImported = results.sleep.imported + results.metrics.imported + results.workouts.imported;

await prisma.importLog.update({
where: { id: importLog.id },
data: {
status: 'success',
recordsImported: totalImported,
completedAt: new Date(),
durationMs: Date.now() - startTime,
},
});

return NextResponse.json({
imported: results,
});
} catch (error) {
await prisma.importLog.update({
where: { id: importLog.id },
data: {
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown',
completedAt: new Date(),
},
});
return NextResponse.json(
{ error: 'Import failed' },
{ status: 500 }
);
}
}