// // MoodDataSummarizer.swift // Reflect // // Created by Claude Code on 12/13/24. // import Foundation /// Summary of mood data for a specific time period, formatted for AI consumption struct MoodDataSummary { let periodName: String let totalEntries: Int let dateRange: String // Mood distribution let moodCounts: [String: Int] let moodPercentages: [String: Int] let averageMoodScore: Double // Temporal patterns let weekdayAverages: [String: Double] let weekendAverage: Double let weekdayAverage: Double let bestDayOfWeek: String let worstDayOfWeek: String // Trends let recentTrend: String let trendMagnitude: Double // Streaks let currentLoggingStreak: Int let longestLoggingStreak: Int let longestPositiveStreak: Int let longestNegativeStreak: Int // Variability let moodSwingCount: Int let moodStabilityScore: Double // Recent context let last7DaysAverage: Double let last7DaysMoods: [String] // Notable observations let hasAllMoodTypes: Bool let missingMoodTypes: [String] // Health data for AI analysis (optional) let healthAverages: HealthService.HealthAverages? // Tag-mood correlations let tagFrequencies: [String: Int] let goodDayTags: [String: Int] // tag counts for entries with mood good/great let badDayTags: [String: Int] // tag counts for entries with mood bad/horrible // Weather-mood correlation let weatherMoodAverages: [String: Double] // condition -> avg mood (1-5 scale) let tempRangeMoodAverages: [String: Double] // "Cold"/"Mild"/"Warm"/"Hot" -> avg mood // Absence patterns let loggingGapCount: Int // number of 2+ day gaps let preGapMoodAverage: Double // avg mood in 3 days before a gap let postGapMoodAverage: Double // avg mood in 3 days after returning // Entry source breakdown let entrySourceBreakdown: [String: Int] // source name -> count } /// Transforms raw MoodEntryModel data into AI-optimized summaries class MoodDataSummarizer { private let calendar = Calendar.current private let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium return formatter }() // MARK: - Main Summarization func summarize(entries: [MoodEntryModel], periodName: String, healthAverages: HealthService.HealthAverages? = nil) -> MoodDataSummary { let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) } guard !validEntries.isEmpty else { return emptyDataSummary(periodName: periodName) } let sortedEntries = validEntries.sorted { $0.forDate < $1.forDate } // Calculate all metrics let moodDistribution = calculateMoodDistribution(entries: validEntries) let temporalPatterns = calculateTemporalPatterns(entries: validEntries) let trend = calculateTrend(entries: sortedEntries) let streaks = calculateStreaks(entries: sortedEntries) let variability = calculateVariability(entries: sortedEntries) let recentContext = calculateRecentContext(entries: sortedEntries) let moodTypes = calculateMoodTypes(entries: validEntries) // Format date range let dateRange = formatDateRange(entries: sortedEntries) let tagAnalysis = calculateTagAnalysis(entries: validEntries) let weatherAnalysis = calculateWeatherAnalysis(entries: validEntries) let absencePatterns = calculateAbsencePatterns(entries: sortedEntries) let sourceBreakdown = calculateEntrySourceBreakdown(entries: validEntries) return MoodDataSummary( periodName: periodName, totalEntries: validEntries.count, dateRange: dateRange, moodCounts: moodDistribution.counts, moodPercentages: moodDistribution.percentages, averageMoodScore: moodDistribution.average, weekdayAverages: temporalPatterns.weekdayAverages, weekendAverage: temporalPatterns.weekendAverage, weekdayAverage: temporalPatterns.weekdayAverage, bestDayOfWeek: temporalPatterns.bestDay, worstDayOfWeek: temporalPatterns.worstDay, recentTrend: trend.direction, trendMagnitude: trend.magnitude, currentLoggingStreak: streaks.current, longestLoggingStreak: streaks.longest, longestPositiveStreak: streaks.longestPositive, longestNegativeStreak: streaks.longestNegative, moodSwingCount: variability.swingCount, moodStabilityScore: variability.stabilityScore, last7DaysAverage: recentContext.average, last7DaysMoods: recentContext.moods, hasAllMoodTypes: moodTypes.hasAll, missingMoodTypes: moodTypes.missing, healthAverages: healthAverages, tagFrequencies: tagAnalysis.frequencies, goodDayTags: tagAnalysis.goodDayTags, badDayTags: tagAnalysis.badDayTags, weatherMoodAverages: weatherAnalysis.conditionAverages, tempRangeMoodAverages: weatherAnalysis.tempRangeAverages, loggingGapCount: absencePatterns.gapCount, preGapMoodAverage: absencePatterns.preGapAverage, postGapMoodAverage: absencePatterns.postGapAverage, entrySourceBreakdown: sourceBreakdown ) } // MARK: - Mood Distribution private func calculateMoodDistribution(entries: [MoodEntryModel]) -> (counts: [String: Int], percentages: [String: Int], average: Double) { var counts: [String: Int] = [:] var totalScore = 0 for entry in entries { let moodName = entry.mood.widgetDisplayName.lowercased() counts[moodName, default: 0] += 1 // Use 1-5 scale (add 1 to raw 0-4 values) for human-readable averages totalScore += Int(entry.moodValue) + 1 } var percentages: [String: Int] = [:] for (mood, count) in counts { percentages[mood] = Int((Double(count) / Double(entries.count)) * 100) } let average = Double(totalScore) / Double(entries.count) return (counts, percentages, average) } // MARK: - Temporal Patterns private func calculateTemporalPatterns(entries: [MoodEntryModel]) -> (weekdayAverages: [String: Double], weekendAverage: Double, weekdayAverage: Double, bestDay: String, worstDay: String) { let weekdayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] var weekdayTotals: [Int: (total: Int, count: Int)] = [:] for entry in entries { let weekday = Int(entry.weekDay) let current = weekdayTotals[weekday, default: (0, 0)] // Use 1-5 scale (add 1 to raw 0-4 values) weekdayTotals[weekday] = (current.total + Int(entry.moodValue) + 1, current.count + 1) } var weekdayAverages: [String: Double] = [:] var bestDay = "Monday" var worstDay = "Monday" var bestAvg = -1.0 var worstAvg = 6.0 for (weekday, data) in weekdayTotals { let avg = Double(data.total) / Double(data.count) let dayName = weekdayNames[weekday - 1] weekdayAverages[dayName] = avg if avg > bestAvg { bestAvg = avg bestDay = dayName } if avg < worstAvg { worstAvg = avg worstDay = dayName } } // Weekend vs weekday (use 1-5 scale) let weekendEntries = entries.filter { [1, 7].contains(Int($0.weekDay)) } let weekdayEntries = entries.filter { ![1, 7].contains(Int($0.weekDay)) } let weekendAvg = weekendEntries.isEmpty ? 0 : Double(weekendEntries.reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(weekendEntries.count) let weekdayAvg = weekdayEntries.isEmpty ? 0 : Double(weekdayEntries.reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(weekdayEntries.count) return (weekdayAverages, weekendAvg, weekdayAvg, bestDay, worstDay) } // MARK: - Trend Analysis private func calculateTrend(entries: [MoodEntryModel]) -> (direction: String, magnitude: Double) { guard entries.count >= 4 else { return ("stable", 0.0) } let halfCount = entries.count / 2 let firstHalf = Array(entries.prefix(halfCount)) let secondHalf = Array(entries.suffix(halfCount)) // Use 1-5 scale let firstAvg = Double(firstHalf.reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(firstHalf.count) let secondAvg = Double(secondHalf.reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(secondHalf.count) let diff = secondAvg - firstAvg let direction: String if diff > 0.5 { direction = "improving" } else if diff < -0.5 { direction = "declining" } else { direction = "stable" } return (direction, abs(diff)) } // MARK: - Streak Calculations private func calculateStreaks(entries: [MoodEntryModel]) -> (current: Int, longest: Int, longestPositive: Int, longestNegative: Int) { let sortedByDateDesc = entries.sorted { $0.forDate > $1.forDate } // Current logging streak var currentStreak = 0 let today = calendar.startOfDay(for: Date()) if let mostRecent = sortedByDateDesc.first?.forDate { let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! if calendar.isDate(mostRecent, inSameDayAs: today) || calendar.isDate(mostRecent, inSameDayAs: yesterday) { currentStreak = 1 var checkDate = calendar.date(byAdding: .day, value: -1, to: mostRecent)! for entry in sortedByDateDesc.dropFirst() { if calendar.isDate(entry.forDate, inSameDayAs: checkDate) { currentStreak += 1 checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate)! } else { break } } } } // Longest logging streak var longestStreak = 1 var tempStreak = 1 let sortedByDateAsc = entries.sorted { $0.forDate < $1.forDate } for i in 1.. Int { guard !entries.isEmpty else { return 0 } // Sort by date to ensure proper ordering let sortedEntries = entries.sorted { $0.forDate < $1.forDate } var longest = 0 var current = 0 var previousDate: Date? for entry in sortedEntries { let entryDate = calendar.startOfDay(for: entry.forDate) // Check if this is a consecutive calendar day from the previous entry let isConsecutive: Bool if let prevDate = previousDate { let dayDiff = calendar.dateComponents([.day], from: prevDate, to: entryDate).day ?? 0 isConsecutive = dayDiff == 1 } else { isConsecutive = true // First entry starts a potential streak } if moods.contains(entry.mood) { if isConsecutive || previousDate == nil { current += 1 } else { current = 1 // Reset to 1 (this entry starts new streak) } longest = max(longest, current) } else { current = 0 } previousDate = entryDate } return longest } // MARK: - Variability private func calculateVariability(entries: [MoodEntryModel]) -> (swingCount: Int, stabilityScore: Double) { guard entries.count >= 2 else { return (0, 1.0) } var swings = 0 for i in 1..= 2 { swings += 1 } } let swingRate = Double(swings) / Double(entries.count - 1) let stabilityScore = 1.0 - min(swingRate, 1.0) return (swings, stabilityScore) } // MARK: - Recent Context private func calculateRecentContext(entries: [MoodEntryModel]) -> (average: Double, moods: [String]) { let recentEntries = entries.suffix(7) guard !recentEntries.isEmpty else { return (0, []) } // Use 1-5 scale let average = Double(recentEntries.reduce(0) { $0 + Int($1.moodValue) + 1 }) / Double(recentEntries.count) let moods = recentEntries.map { $0.mood.widgetDisplayName } return (average, moods) } // MARK: - Mood Types private func calculateMoodTypes(entries: [MoodEntryModel]) -> (hasAll: Bool, missing: [String]) { let allMoods: Set = [.great, .good, .average, .bad, .horrible] let presentMoods = Set(entries.map { $0.mood }) let missing = allMoods.subtracting(presentMoods).map { $0.widgetDisplayName } let hasAll = missing.isEmpty return (hasAll, missing) } // MARK: - Tag Analysis private func calculateTagAnalysis(entries: [MoodEntryModel]) -> (frequencies: [String: Int], goodDayTags: [String: Int], badDayTags: [String: Int]) { var frequencies: [String: Int] = [:] var goodDayTags: [String: Int] = [:] var badDayTags: [String: Int] = [:] for entry in entries { let entryTags = entry.tags guard !entryTags.isEmpty else { continue } for tag in entryTags { let normalizedTag = tag.lowercased() frequencies[normalizedTag, default: 0] += 1 if [.good, .great].contains(entry.mood) { goodDayTags[normalizedTag, default: 0] += 1 } else if [.bad, .horrible].contains(entry.mood) { badDayTags[normalizedTag, default: 0] += 1 } } } return (frequencies, goodDayTags, badDayTags) } // MARK: - Weather Analysis private func calculateWeatherAnalysis(entries: [MoodEntryModel]) -> (conditionAverages: [String: Double], tempRangeAverages: [String: Double]) { var conditionTotals: [String: (total: Int, count: Int)] = [:] var tempRangeTotals: [String: (total: Int, count: Int)] = [:] for entry in entries { guard let json = entry.weatherJSON, let weather = WeatherData.decode(from: json) else { continue } let moodScore = Int(entry.moodValue) + 1 // 1-5 scale // Group by weather condition let condition = weather.condition let current = conditionTotals[condition, default: (0, 0)] conditionTotals[condition] = (current.total + moodScore, current.count + 1) // Group by temperature range (convert Celsius to Fahrenheit) let tempF = weather.temperature * 9.0 / 5.0 + 32.0 let tempRange: String if tempF < 50 { tempRange = "Cold" } else if tempF <= 70 { tempRange = "Mild" } else if tempF <= 85 { tempRange = "Warm" } else { tempRange = "Hot" } let currentTemp = tempRangeTotals[tempRange, default: (0, 0)] tempRangeTotals[tempRange] = (currentTemp.total + moodScore, currentTemp.count + 1) } var conditionAverages: [String: Double] = [:] for (condition, data) in conditionTotals { conditionAverages[condition] = Double(data.total) / Double(data.count) } var tempRangeAverages: [String: Double] = [:] for (range, data) in tempRangeTotals { tempRangeAverages[range] = Double(data.total) / Double(data.count) } return (conditionAverages, tempRangeAverages) } // MARK: - Absence Patterns private func calculateAbsencePatterns(entries: [MoodEntryModel]) -> (gapCount: Int, preGapAverage: Double, postGapAverage: Double) { guard entries.count >= 2 else { return (0, 0, 0) } var gapCount = 0 var preGapScores: [Int] = [] var postGapScores: [Int] = [] for i in 1..= 2 else { continue } gapCount += 1 // Collect up to 3 entries before the gap let preStart = max(0, i - 3) for j in preStart.. [String: Int] { var breakdown: [String: Int] = [:] let sourceNames: [Int: String] = [ 0: "App", 1: "Widget", 2: "Watch", 3: "Shortcut", 4: "Auto-fill", 5: "Notification", 6: "Header", 7: "Siri", 8: "Control Center", 9: "Live Activity" ] for entry in entries { let name = sourceNames[entry.entryType] ?? "Other" breakdown[name, default: 0] += 1 } return breakdown } // MARK: - Helpers private func formatDateRange(entries: [MoodEntryModel]) -> String { guard let first = entries.first, let last = entries.last else { return "No data" } let startDate = dateFormatter.string(from: first.forDate) let endDate = dateFormatter.string(from: last.forDate) return "\(startDate) - \(endDate)" } private func emptyDataSummary(periodName: String) -> MoodDataSummary { MoodDataSummary( periodName: periodName, totalEntries: 0, dateRange: "No data", moodCounts: [:], moodPercentages: [:], averageMoodScore: 0, weekdayAverages: [:], weekendAverage: 0, weekdayAverage: 0, bestDayOfWeek: "N/A", worstDayOfWeek: "N/A", recentTrend: "stable", trendMagnitude: 0, currentLoggingStreak: 0, longestLoggingStreak: 0, longestPositiveStreak: 0, longestNegativeStreak: 0, moodSwingCount: 0, moodStabilityScore: 1.0, last7DaysAverage: 0, last7DaysMoods: [], hasAllMoodTypes: false, missingMoodTypes: ["great", "good", "average", "bad", "horrible"], healthAverages: nil, tagFrequencies: [:], goodDayTags: [:], badDayTags: [:], weatherMoodAverages: [:], tempRangeMoodAverages: [:], loggingGapCount: 0, preGapMoodAverage: 0, postGapMoodAverage: 0, entrySourceBreakdown: [:] ) } // MARK: - Prompt String Generation /// Generates a concise prompt string optimized for Apple's 4096 token context limit func toPromptString(_ summary: MoodDataSummary) -> String { // Compact format to stay under token limit var lines: [String] = [] lines.append("Period: \(summary.periodName), \(summary.totalEntries) entries, avg \(String(format: "%.1f", summary.averageMoodScore))/5") // Mood distribution - compact let moodDist = summary.moodPercentages.sorted { $0.key < $1.key } .map { "\($0.key): \($0.value)%" } .joined(separator: ", ") lines.append("Moods: \(moodDist)") // Day patterns - only best/worst lines.append("Best day: \(summary.bestDayOfWeek), Worst: \(summary.worstDayOfWeek)") lines.append("Weekend avg: \(String(format: "%.1f", summary.weekendAverage)), Weekday avg: \(String(format: "%.1f", summary.weekdayAverage))") // Trends lines.append("Trend: \(summary.recentTrend), Last 7 days avg: \(String(format: "%.1f", summary.last7DaysAverage))") // Streaks - compact lines.append("Streaks - Current: \(summary.currentLoggingStreak)d, Longest: \(summary.longestLoggingStreak)d, Best positive: \(summary.longestPositiveStreak)d, Worst negative: \(summary.longestNegativeStreak)d") // Stability lines.append("Stability: \(String(format: "%.0f", summary.moodStabilityScore * 100))%, Mood swings: \(summary.moodSwingCount)") // 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.") } // Tag-mood correlations (only if tags exist) if !summary.tagFrequencies.isEmpty { let topTags = summary.tagFrequencies.sorted { $0.value > $1.value }.prefix(8) .map { "\($0.key)(\($0.value))" }.joined(separator: ", ") lines.append("Themes: \(topTags)") if !summary.goodDayTags.isEmpty { let goodTags = summary.goodDayTags.sorted { $0.value > $1.value }.prefix(5) .map { "\($0.key)(\($0.value))" }.joined(separator: ", ") lines.append("Good day themes: \(goodTags)") } if !summary.badDayTags.isEmpty { let badTags = summary.badDayTags.sorted { $0.value > $1.value }.prefix(5) .map { "\($0.key)(\($0.value))" }.joined(separator: ", ") lines.append("Bad day themes: \(badTags)") } } // Weather-mood (only if weather data exists) if !summary.weatherMoodAverages.isEmpty { let weatherMood = summary.weatherMoodAverages.sorted { $0.value > $1.value } .map { "\($0.key) avg \(String(format: "%.1f", $0.value))" }.joined(separator: ", ") lines.append("Weather-mood: \(weatherMood)") } if !summary.tempRangeMoodAverages.isEmpty { let tempMood = ["Cold", "Mild", "Warm", "Hot"].compactMap { range -> String? in guard let avg = summary.tempRangeMoodAverages[range] else { return nil } return "\(range) avg \(String(format: "%.1f", avg))" }.joined(separator: ", ") if !tempMood.isEmpty { lines.append("Temp-mood: \(tempMood)") } } // Gaps (only if gaps exist) if summary.loggingGapCount > 0 { lines.append("Logging gaps: \(summary.loggingGapCount) breaks of 2+ days. Pre-gap avg: \(String(format: "%.1f", summary.preGapMoodAverage))/5, Post-return avg: \(String(format: "%.1f", summary.postGapMoodAverage))/5") } // Sources (only if multiple sources) if summary.entrySourceBreakdown.count > 1 { let total = Double(summary.entrySourceBreakdown.values.reduce(0, +)) let sources = summary.entrySourceBreakdown.sorted { $0.value > $1.value } .map { "\($0.key) \(Int(Double($0.value) / total * 100))%" }.joined(separator: ", ") lines.append("Entry sources: \(sources)") } return lines.joined(separator: "\n") } }