Add HealthKit backfill to sync all existing moods
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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] {
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user