From 373d4816133ade28b6a014d319f1252b0795dcec Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 20 Dec 2025 00:55:25 -0600 Subject: [PATCH] Add HealthKit backfill to sync all existing moods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When user enables HealthKit integration, automatically syncs all previously recorded mood entries to Apple Health. Features: - Progress tracking with visual indicator in Settings - Batched saves with throttling to avoid overwhelming HealthKit - Success/failure logging with EventLogger 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Shared/HealthKitManager.swift | 76 ++++++++++++++++++++ Shared/Views/SettingsView/SettingsView.swift | 30 ++++++++ 2 files changed, 106 insertions(+) diff --git a/Shared/HealthKitManager.swift b/Shared/HealthKitManager.swift index e605653..6021671 100644 --- a/Shared/HealthKitManager.swift +++ b/Shared/HealthKitManager.swift @@ -7,15 +7,21 @@ import Foundation import HealthKit +import os.log @MainActor class HealthKitManager: ObservableObject { static let shared = HealthKitManager() private let healthStore = HKHealthStore() + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.ifeel", category: "HealthKit") @Published var isAuthorized = false @Published var authorizationError: Error? + @Published var isSyncing = false + @Published var syncProgress: Double = 0 + @Published var syncedCount: Int = 0 + @Published var totalToSync: Int = 0 // State of Mind sample type private var stateOfMindType: HKSampleType? { @@ -80,6 +86,76 @@ class HealthKitManager: ObservableObject { try await healthStore.save(stateOfMind) } + // MARK: - Sync All Existing Moods to HealthKit + + /// Syncs all existing mood entries from the app to HealthKit + /// Call this after HealthKit authorization is granted to backfill historical data + func syncAllMoods() async { + guard isHealthKitAvailable else { + logger.warning("HealthKit not available, skipping sync") + return + } + + guard checkAuthorizationStatus() == .sharingAuthorized else { + logger.warning("HealthKit not authorized for sharing, skipping sync") + return + } + + isSyncing = true + syncProgress = 0 + syncedCount = 0 + + // Fetch all mood entries + let allEntries = DataController.shared.getData( + startDate: Date(timeIntervalSince1970: 0), + endDate: Date(), + includedDays: [1, 2, 3, 4, 5, 6, 7] + ) + + // Filter out missing and placeholder entries + let validEntries = allEntries.filter { entry in + entry.mood != .missing && entry.mood != .placeholder + } + + totalToSync = validEntries.count + logger.info("Starting HealthKit sync for \(validEntries.count) mood entries") + + guard totalToSync > 0 else { + logger.info("No mood entries to sync") + isSyncing = false + return + } + + var successCount = 0 + var failCount = 0 + + for (index, entry) in validEntries.enumerated() { + do { + try await saveMood(entry.mood, for: entry.forDate, note: entry.notes) + successCount += 1 + } catch { + failCount += 1 + logger.error("Failed to sync mood for \(entry.forDate): \(error.localizedDescription)") + } + + syncedCount = index + 1 + syncProgress = Double(syncedCount) / Double(totalToSync) + + // Add a small delay to avoid overwhelming HealthKit + if index % 10 == 0 && index > 0 { + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms + } + } + + isSyncing = false + logger.info("HealthKit sync complete: \(successCount) succeeded, \(failCount) failed") + EventLogger.log(event: "healthkit_sync_complete", withData: [ + "total": totalToSync, + "success": successCount, + "failed": failCount + ]) + } + // MARK: - Read Mood from HealthKit func fetchMoods(from startDate: Date, to endDate: Date) async throws -> [HKStateOfMind] { diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift index 01bd8ef..a95fb43 100644 --- a/Shared/Views/SettingsView/SettingsView.swift +++ b/Shared/Views/SettingsView/SettingsView.swift @@ -238,6 +238,8 @@ struct SettingsContentView: View { // Request write permissions for State of Mind sync do { try await HealthKitManager.shared.requestAuthorization() + // Sync all existing moods to HealthKit + await HealthKitManager.shared.syncAllMoods() } catch { print("HealthKit write authorization failed: \(error)") } @@ -259,6 +261,19 @@ struct SettingsContentView: View { } } .padding() + + // Show sync progress + if HealthKitManager.shared.isSyncing { + VStack(spacing: 8) { + ProgressView(value: HealthKitManager.shared.syncProgress) + .tint(.red) + Text("Syncing moods to Health... \(HealthKitManager.shared.syncedCount)/\(HealthKitManager.shared.totalToSync)") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.bottom, 8) + } } .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) @@ -708,6 +723,8 @@ struct SettingsView: View { // Request write permissions for State of Mind sync do { try await HealthKitManager.shared.requestAuthorization() + // Sync all existing moods to HealthKit + await HealthKitManager.shared.syncAllMoods() } catch { print("HealthKit write authorization failed: \(error)") } @@ -729,6 +746,19 @@ struct SettingsView: View { } } .padding() + + // Show sync progress + if HealthKitManager.shared.isSyncing { + VStack(spacing: 8) { + ProgressView(value: HealthKitManager.shared.syncProgress) + .tint(.red) + Text("Syncing moods to Health... \(HealthKitManager.shared.syncedCount)/\(HealthKitManager.shared.totalToSync)") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.bottom, 8) + } } .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])