diff --git a/Shared/HealthKitManager.swift b/Shared/HealthKitManager.swift index 67df702..535e85d 100644 --- a/Shared/HealthKitManager.swift +++ b/Shared/HealthKitManager.swift @@ -30,11 +30,26 @@ class HealthKitManager: ObservableObject { } // Health data types for insights (read-only) + // Core activity metrics private let stepCountType = HKQuantityType.quantityType(forIdentifier: .stepCount)! private let exerciseTimeType = HKQuantityType.quantityType(forIdentifier: .appleExerciseTime)! + private let activeEnergyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)! + private let distanceType = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)! + + // Heart & stress indicators private let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)! + private let restingHeartRateType = HKQuantityType.quantityType(forIdentifier: .restingHeartRate)! + private let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)! + + // Sleep & recovery private let sleepAnalysisType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)! + // Mindfulness + private let mindfulSessionType = HKCategoryType.categoryType(forIdentifier: .mindfulSession)! + + // Workouts + private let workoutType = HKWorkoutType.workoutType() + // MARK: - Authorization var isHealthKitAvailable: Bool { @@ -55,16 +70,32 @@ class HealthKitManager: ObservableObject { // Write permission for State of Mind let typesToShare: Set = [stateOfMindType] - // Read permissions for insights + State of Mind + // Read permissions for mood-health correlation insights + // These help Apple's AI provide personalized health insights let typesToRead: Set = [ + // State of Mind (read back our own data) stateOfMindType, + + // Activity - correlates with mood and energy levels stepCountType, exerciseTimeType, + activeEnergyType, + distanceType, + workoutType, + + // Heart metrics - stress and recovery indicators heartRateType, - sleepAnalysisType + restingHeartRateType, + hrvType, + + // Sleep - strong correlation with mood + sleepAnalysisType, + + // Mindfulness - meditation impact on mood + mindfulSessionType ] - logger.info("Requesting HealthKit permissions: share=1, read=5") + logger.info("Requesting HealthKit permissions: share=1, read=\(typesToRead.count)") try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) diff --git a/Shared/Services/FoundationModelsInsightService.swift b/Shared/Services/FoundationModelsInsightService.swift index e0cd503..d5eac92 100644 --- a/Shared/Services/FoundationModelsInsightService.swift +++ b/Shared/Services/FoundationModelsInsightService.swift @@ -185,13 +185,13 @@ class FoundationModelsInsightService: ObservableObject { /// - entries: Array of mood entries to analyze /// - periodName: The time period name (e.g., "this month", "this year", "all time") /// - count: Number of insights to generate (default 5) - /// - healthCorrelations: Optional health data correlations to include + /// - healthAverages: Optional raw health data for AI to analyze correlations /// - Returns: Array of Insight objects func generateInsights( for entries: [MoodEntryModel], periodName: String, count: Int = 5, - healthCorrelations: [HealthCorrelation] = [] + healthAverages: HealthService.HealthAverages? = nil ) async throws -> [Insight] { // Check cache first if let cached = cachedInsights[periodName], @@ -216,8 +216,8 @@ class FoundationModelsInsightService: ObservableObject { isGenerating = true defer { isGenerating = false } - // Prepare data summary with health correlations - let summary = summarizer.summarize(entries: validEntries, periodName: periodName, healthCorrelations: healthCorrelations) + // Prepare data summary with health data for AI analysis + let summary = summarizer.summarize(entries: validEntries, periodName: periodName, healthAverages: healthAverages) let prompt = buildPrompt(from: summary, count: count) do { diff --git a/Shared/Services/HealthService.swift b/Shared/Services/HealthService.swift index e7090d2..7050ebc 100644 --- a/Shared/Services/HealthService.swift +++ b/Shared/Services/HealthService.swift @@ -30,14 +30,35 @@ class HealthService: ObservableObject { // MARK: - Data Types + // Core activity metrics private let stepCountType = HKQuantityType.quantityType(forIdentifier: .stepCount)! private let exerciseTimeType = HKQuantityType.quantityType(forIdentifier: .appleExerciseTime)! + private let activeEnergyType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)! + private let distanceType = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)! + + // Heart & stress indicators private let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)! + private let restingHeartRateType = HKQuantityType.quantityType(forIdentifier: .restingHeartRate)! + private let hrvType = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN)! + + // Sleep & recovery private let sleepAnalysisType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)! + + // Mindfulness + private let mindfulSessionType = HKCategoryType.categoryType(forIdentifier: .mindfulSession)! + + // State of Mind private let stateOfMindType = HKSampleType.stateOfMindType() + // Workouts + private let workoutType = HKWorkoutType.workoutType() + private var readTypes: Set { - [stepCountType, exerciseTimeType, heartRateType, sleepAnalysisType, stateOfMindType] + [ + stepCountType, exerciseTimeType, activeEnergyType, distanceType, + heartRateType, restingHeartRateType, hrvType, + sleepAnalysisType, mindfulSessionType, stateOfMindType, workoutType + ] } // MARK: - Initialization @@ -71,13 +92,25 @@ class HealthService: ObservableObject { struct DailyHealthData { let date: Date + + // Activity metrics let steps: Int? let exerciseMinutes: Int? + let activeCalories: Int? + let distanceKm: Double? + + // Heart metrics let averageHeartRate: Double? + let restingHeartRate: Double? + let hrv: Double? // Heart Rate Variability in ms + + // Recovery metrics let sleepHours: Double? + let mindfulMinutes: Int? var hasData: Bool { - steps != nil || exerciseMinutes != nil || averageHeartRate != nil || sleepHours != nil + steps != nil || exerciseMinutes != nil || averageHeartRate != nil || + sleepHours != nil || hrv != nil || mindfulMinutes != nil } } @@ -86,17 +119,28 @@ class HealthService: ObservableObject { let startOfDay = calendar.startOfDay(for: date) let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! + // Fetch all metrics concurrently async let steps = fetchSteps(start: startOfDay, end: endOfDay) async let exercise = fetchExerciseMinutes(start: startOfDay, end: endOfDay) + async let activeCalories = fetchActiveCalories(start: startOfDay, end: endOfDay) + async let distance = fetchDistance(start: startOfDay, end: endOfDay) async let heartRate = fetchAverageHeartRate(start: startOfDay, end: endOfDay) + async let restingHR = fetchRestingHeartRate(start: startOfDay, end: endOfDay) + async let hrv = fetchHRV(start: startOfDay, end: endOfDay) async let sleep = fetchSleepHours(for: date) + async let mindful = fetchMindfulMinutes(start: startOfDay, end: endOfDay) return await DailyHealthData( date: date, steps: steps, exerciseMinutes: exercise, + activeCalories: activeCalories, + distanceKm: distance, averageHeartRate: heartRate, - sleepHours: sleep + restingHeartRate: restingHR, + hrv: hrv, + sleepHours: sleep, + mindfulMinutes: mindful ) } @@ -231,6 +275,153 @@ class HealthService: ObservableObject { } } + // MARK: - Active Calories + + private func fetchActiveCalories(start: Date, end: Date) async -> Int? { + guard isAuthorized else { return nil } + + let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) + + return await withCheckedContinuation { continuation in + let query = HKStatisticsQuery( + quantityType: activeEnergyType, + quantitySamplePredicate: predicate, + options: .cumulativeSum + ) { _, result, error in + guard error == nil, + let sum = result?.sumQuantity() else { + continuation.resume(returning: nil) + return + } + + let calories = Int(sum.doubleValue(for: .kilocalorie())) + continuation.resume(returning: calories) + } + + healthStore.execute(query) + } + } + + // MARK: - Distance + + private func fetchDistance(start: Date, end: Date) async -> Double? { + guard isAuthorized else { return nil } + + let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) + + return await withCheckedContinuation { continuation in + let query = HKStatisticsQuery( + quantityType: distanceType, + quantitySamplePredicate: predicate, + options: .cumulativeSum + ) { _, result, error in + guard error == nil, + let sum = result?.sumQuantity() else { + continuation.resume(returning: nil) + return + } + + let km = sum.doubleValue(for: .meterUnit(with: .kilo)) + continuation.resume(returning: km) + } + + healthStore.execute(query) + } + } + + // MARK: - Resting Heart Rate + + private func fetchRestingHeartRate(start: Date, end: Date) async -> Double? { + guard isAuthorized else { return nil } + + let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) + + return await withCheckedContinuation { continuation in + let query = HKStatisticsQuery( + quantityType: restingHeartRateType, + quantitySamplePredicate: predicate, + options: .discreteAverage + ) { _, result, error in + guard error == nil, + let avg = result?.averageQuantity() else { + continuation.resume(returning: nil) + return + } + + let bpm = avg.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) + continuation.resume(returning: bpm) + } + + healthStore.execute(query) + } + } + + // MARK: - Heart Rate Variability (HRV) + + private func fetchHRV(start: Date, end: Date) async -> Double? { + guard isAuthorized else { return nil } + + let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) + + return await withCheckedContinuation { continuation in + let query = HKStatisticsQuery( + quantityType: hrvType, + quantitySamplePredicate: predicate, + options: .discreteAverage + ) { _, result, error in + guard error == nil, + let avg = result?.averageQuantity() else { + continuation.resume(returning: nil) + return + } + + // HRV is measured in milliseconds + let ms = avg.doubleValue(for: .secondUnit(with: .milli)) + continuation.resume(returning: ms) + } + + healthStore.execute(query) + } + } + + // MARK: - Mindful Minutes + + private func fetchMindfulMinutes(start: Date, end: Date) async -> Int? { + guard isAuthorized else { return nil } + + let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate) + + return await withCheckedContinuation { continuation in + let query = HKSampleQuery( + sampleType: mindfulSessionType, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: nil + ) { _, samples, error in + guard error == nil, + let mindfulSamples = samples as? [HKCategorySample] else { + continuation.resume(returning: nil) + return + } + + // Sum up all mindful session durations + var totalSeconds: TimeInterval = 0 + for sample in mindfulSamples { + totalSeconds += sample.endDate.timeIntervalSince(sample.startDate) + } + + if totalSeconds > 0 { + let minutes = Int(totalSeconds / 60) + continuation.resume(returning: minutes) + } else { + continuation.resume(returning: nil) + } + } + + healthStore.execute(query) + } + } + // MARK: - Batch Fetch for Insights func fetchHealthData(for entries: [MoodEntryModel]) async -> [Date: DailyHealthData] { @@ -259,121 +450,78 @@ class HealthService: ObservableObject { return results } - // MARK: - Correlation Analysis + // MARK: - Health Averages for AI Analysis - struct HealthMoodCorrelation { - let metric: String - let correlation: String // "positive", "negative", or "none" - let insight: String - let averageWithHighMetric: Double - let averageWithLowMetric: Double + /// Aggregated health metrics for a period - passed to AI for correlation analysis + struct HealthAverages { + // Activity + let avgSteps: Int? + let avgExerciseMinutes: Int? + let avgActiveCalories: Int? + let avgDistanceKm: Double? + + // Heart health + let avgHeartRate: Double? + let avgRestingHeartRate: Double? + let avgHRV: Double? // milliseconds + + // Recovery + let avgSleepHours: Double? + let avgMindfulMinutes: Int? + + // Sample sizes (for AI context) + let daysWithHealthData: Int + let totalMoodEntries: Int + + var hasData: Bool { + daysWithHealthData > 0 + } } - func analyzeCorrelations(entries: [MoodEntryModel], healthData: [Date: DailyHealthData]) -> [HealthMoodCorrelation] { - var correlations: [HealthMoodCorrelation] = [] + /// Compute aggregate health averages for a set of mood entries + /// The AI will analyze these alongside mood data to find correlations + func computeHealthAverages(entries: [MoodEntryModel], healthData: [Date: DailyHealthData]) -> HealthAverages { let calendar = Calendar.current - // Prepare data pairs - var stepsAndMoods: [(steps: Int, mood: Int)] = [] - var exerciseAndMoods: [(minutes: Int, mood: Int)] = [] - var sleepAndMoods: [(hours: Double, mood: Int)] = [] - var heartRateAndMoods: [(bpm: Double, mood: Int)] = [] + var steps: [Int] = [] + var exercise: [Int] = [] + var calories: [Int] = [] + var distance: [Double] = [] + var heartRate: [Double] = [] + var restingHR: [Double] = [] + var hrv: [Double] = [] + var sleep: [Double] = [] + var mindful: [Int] = [] for entry in entries { let date = calendar.startOfDay(for: entry.forDate) guard let health = healthData[date] else { continue } - let moodValue = entry.moodValue + 1 // Use 1-5 scale - - if let steps = health.steps { - stepsAndMoods.append((steps, moodValue)) - } - if let exercise = health.exerciseMinutes { - exerciseAndMoods.append((exercise, moodValue)) - } - if let sleep = health.sleepHours { - sleepAndMoods.append((sleep, moodValue)) - } - if let hr = health.averageHeartRate { - heartRateAndMoods.append((hr, moodValue)) - } + if let s = health.steps { steps.append(s) } + if let e = health.exerciseMinutes { exercise.append(e) } + if let c = health.activeCalories { calories.append(c) } + if let d = health.distanceKm { distance.append(d) } + if let h = health.averageHeartRate { heartRate.append(h) } + if let r = health.restingHeartRate { restingHR.append(r) } + if let v = health.hrv { hrv.append(v) } + if let sl = health.sleepHours { sleep.append(sl) } + if let m = health.mindfulMinutes { mindful.append(m) } } - // Analyze steps correlation - if stepsAndMoods.count >= 5 { - let threshold = 8000 - let highSteps = stepsAndMoods.filter { $0.steps >= threshold } - let lowSteps = stepsAndMoods.filter { $0.steps < threshold } + let daysWithData = Set(healthData.keys).count - if !highSteps.isEmpty && !lowSteps.isEmpty { - let avgHigh = Double(highSteps.map { $0.mood }.reduce(0, +)) / Double(highSteps.count) - let avgLow = Double(lowSteps.map { $0.mood }.reduce(0, +)) / Double(lowSteps.count) - let diff = avgHigh - avgLow - - if abs(diff) >= 0.3 { - correlations.append(HealthMoodCorrelation( - metric: "Steps", - correlation: diff > 0 ? "positive" : "negative", - insight: diff > 0 - ? "Your mood averages \(String(format: "%.1f", diff)) points higher on days with 8k+ steps" - : "Interestingly, your mood is slightly lower on high-step days", - averageWithHighMetric: avgHigh, - averageWithLowMetric: avgLow - )) - } - } - } - - // Analyze sleep correlation - if sleepAndMoods.count >= 5 { - let threshold = 7.0 - let goodSleep = sleepAndMoods.filter { $0.hours >= threshold } - let poorSleep = sleepAndMoods.filter { $0.hours < threshold } - - if !goodSleep.isEmpty && !poorSleep.isEmpty { - let avgGood = Double(goodSleep.map { $0.mood }.reduce(0, +)) / Double(goodSleep.count) - let avgPoor = Double(poorSleep.map { $0.mood }.reduce(0, +)) / Double(poorSleep.count) - let diff = avgGood - avgPoor - - if abs(diff) >= 0.3 { - correlations.append(HealthMoodCorrelation( - metric: "Sleep", - correlation: diff > 0 ? "positive" : "negative", - insight: diff > 0 - ? "7+ hours of sleep correlates with \(String(format: "%.1f", diff)) point higher mood" - : "Sleep duration doesn't seem to strongly affect your mood", - averageWithHighMetric: avgGood, - averageWithLowMetric: avgPoor - )) - } - } - } - - // Analyze exercise correlation - if exerciseAndMoods.count >= 5 { - let threshold = 30 - let active = exerciseAndMoods.filter { $0.minutes >= threshold } - let inactive = exerciseAndMoods.filter { $0.minutes < threshold } - - if !active.isEmpty && !inactive.isEmpty { - let avgActive = Double(active.map { $0.mood }.reduce(0, +)) / Double(active.count) - let avgInactive = Double(inactive.map { $0.mood }.reduce(0, +)) / Double(inactive.count) - let diff = avgActive - avgInactive - - if abs(diff) >= 0.3 { - correlations.append(HealthMoodCorrelation( - metric: "Exercise", - correlation: diff > 0 ? "positive" : "negative", - insight: diff > 0 - ? "30+ minutes of exercise correlates with \(String(format: "%.1f", diff)) point mood boost" - : "Exercise doesn't show a strong mood correlation for you", - averageWithHighMetric: avgActive, - averageWithLowMetric: avgInactive - )) - } - } - } - - return correlations + return HealthAverages( + avgSteps: steps.isEmpty ? nil : steps.reduce(0, +) / steps.count, + avgExerciseMinutes: exercise.isEmpty ? nil : exercise.reduce(0, +) / exercise.count, + avgActiveCalories: calories.isEmpty ? nil : calories.reduce(0, +) / calories.count, + avgDistanceKm: distance.isEmpty ? nil : distance.reduce(0, +) / Double(distance.count), + avgHeartRate: heartRate.isEmpty ? nil : heartRate.reduce(0, +) / Double(heartRate.count), + avgRestingHeartRate: restingHR.isEmpty ? nil : restingHR.reduce(0, +) / Double(restingHR.count), + avgHRV: hrv.isEmpty ? nil : hrv.reduce(0, +) / Double(hrv.count), + avgSleepHours: sleep.isEmpty ? nil : sleep.reduce(0, +) / Double(sleep.count), + avgMindfulMinutes: mindful.isEmpty ? nil : mindful.reduce(0, +) / mindful.count, + daysWithHealthData: daysWithData, + totalMoodEntries: entries.count + ) } } diff --git a/Shared/Services/MoodDataSummarizer.swift b/Shared/Services/MoodDataSummarizer.swift index 940c7e7..be8960b 100644 --- a/Shared/Services/MoodDataSummarizer.swift +++ b/Shared/Services/MoodDataSummarizer.swift @@ -47,15 +47,8 @@ struct MoodDataSummary { let hasAllMoodTypes: Bool let missingMoodTypes: [String] - // Health correlations (optional) - let healthCorrelations: [HealthCorrelation] -} - -/// Health correlation data for AI insights -struct HealthCorrelation { - let metric: String - let insight: String - let correlation: String // "positive", "negative", or "none" + // Health data for AI analysis (optional) + let healthAverages: HealthService.HealthAverages? } /// Transforms raw MoodEntryModel data into AI-optimized summaries @@ -69,7 +62,7 @@ class MoodDataSummarizer { // MARK: - Main Summarization - func summarize(entries: [MoodEntryModel], periodName: String, healthCorrelations: [HealthCorrelation] = []) -> MoodDataSummary { + func summarize(entries: [MoodEntryModel], periodName: String, healthAverages: HealthService.HealthAverages? = nil) -> MoodDataSummary { let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) } guard !validEntries.isEmpty else { @@ -114,7 +107,7 @@ class MoodDataSummarizer { last7DaysMoods: recentContext.moods, hasAllMoodTypes: moodTypes.hasAll, missingMoodTypes: moodTypes.missing, - healthCorrelations: healthCorrelations + healthAverages: healthAverages ) } @@ -391,7 +384,7 @@ class MoodDataSummarizer { last7DaysMoods: [], hasAllMoodTypes: false, missingMoodTypes: ["great", "good", "average", "bad", "horrible"], - healthCorrelations: [] + healthAverages: nil ) } @@ -423,12 +416,57 @@ class MoodDataSummarizer { // Stability lines.append("Stability: \(String(format: "%.0f", summary.moodStabilityScore * 100))%, Mood swings: \(summary.moodSwingCount)") - // Health correlations (if available) - if !summary.healthCorrelations.isEmpty { - lines.append("Health correlations:") - for correlation in summary.healthCorrelations { - lines.append("- \(correlation.metric): \(correlation.insight)") + // Health data for AI analysis (if available) + if let health = summary.healthAverages, health.hasData { + lines.append("") + lines.append("Apple Health data (\(health.daysWithHealthData) days with data):") + + // Activity metrics + var activityMetrics: [String] = [] + if let steps = health.avgSteps { + activityMetrics.append("Steps: \(steps.formatted())/day") } + if let exercise = health.avgExerciseMinutes { + activityMetrics.append("Exercise: \(exercise) min/day") + } + if let calories = health.avgActiveCalories { + activityMetrics.append("Active cal: \(calories)/day") + } + if let distance = health.avgDistanceKm { + activityMetrics.append("Distance: \(String(format: "%.1f", distance)) km/day") + } + if !activityMetrics.isEmpty { + lines.append("Activity: \(activityMetrics.joined(separator: ", "))") + } + + // Heart metrics + var heartMetrics: [String] = [] + if let hr = health.avgHeartRate { + heartMetrics.append("Avg HR: \(Int(hr)) bpm") + } + if let restingHR = health.avgRestingHeartRate { + heartMetrics.append("Resting HR: \(Int(restingHR)) bpm") + } + if let hrv = health.avgHRV { + heartMetrics.append("HRV: \(Int(hrv)) ms") + } + if !heartMetrics.isEmpty { + lines.append("Heart: \(heartMetrics.joined(separator: ", "))") + } + + // Recovery metrics + var recoveryMetrics: [String] = [] + if let sleep = health.avgSleepHours { + recoveryMetrics.append("Sleep: \(String(format: "%.1f", sleep)) hrs/night") + } + if let mindful = health.avgMindfulMinutes { + recoveryMetrics.append("Mindfulness: \(mindful) min/day") + } + if !recoveryMetrics.isEmpty { + lines.append("Recovery: \(recoveryMetrics.joined(separator: ", "))") + } + + lines.append("Analyze how these health metrics may correlate with mood patterns.") } return lines.joined(separator: "\n") diff --git a/Shared/Views/InsightsView/InsightsViewModel.swift b/Shared/Views/InsightsView/InsightsViewModel.swift index 8252ae5..8ee16aa 100644 --- a/Shared/Views/InsightsView/InsightsViewModel.swift +++ b/Shared/Views/InsightsView/InsightsViewModel.swift @@ -149,20 +149,11 @@ class InsightsViewModel: ObservableObject { updateState(.loading) - // Fetch health data if enabled - var healthCorrelations: [HealthCorrelation] = [] + // Fetch health data if enabled - pass raw averages to AI for correlation analysis + var healthAverages: HealthService.HealthAverages? if healthService.isEnabled && healthService.isAuthorized { let healthData = await healthService.fetchHealthData(for: validEntries) - let correlations = healthService.analyzeCorrelations(entries: validEntries, healthData: healthData) - - // Convert to HealthCorrelation format - healthCorrelations = correlations.map { - HealthCorrelation( - metric: $0.metric, - insight: $0.insight, - correlation: $0.correlation - ) - } + healthAverages = healthService.computeHealthAverages(entries: validEntries, healthData: healthData) } do { @@ -170,7 +161,7 @@ class InsightsViewModel: ObservableObject { for: validEntries, periodName: periodName, count: 5, - healthCorrelations: healthCorrelations + healthAverages: healthAverages ) updateInsights(insights) updateState(.loaded)