Files
Reflect/Shared/Services/MoodDataSummarizer.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

475 lines
17 KiB
Swift

//
// 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?
}
/// 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)
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
)
}
// 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..<sortedByDateAsc.count {
let dayDiff = calendar.dateComponents([.day], from: sortedByDateAsc[i-1].forDate, to: sortedByDateAsc[i].forDate).day ?? 0
if dayDiff == 1 {
tempStreak += 1
longestStreak = max(longestStreak, tempStreak)
} else {
tempStreak = 1
}
}
// Positive mood streak (good/great)
let longestPositive = calculateMoodStreak(entries: sortedByDateAsc, moods: [.good, .great])
// Negative mood streak (bad/horrible)
let longestNegative = calculateMoodStreak(entries: sortedByDateAsc, moods: [.bad, .horrible])
return (currentStreak, longestStreak, longestPositive, longestNegative)
}
private func calculateMoodStreak(entries: [MoodEntryModel], moods: [Mood]) -> 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..<entries.count {
let diff = abs(Int(entries[i].moodValue) - Int(entries[i-1].moodValue))
if diff >= 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<Mood> = [.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: - 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
)
}
// 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.")
}
return lines.joined(separator: "\n")
}
}