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>
475 lines
17 KiB
Swift
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")
|
|
}
|
|
}
|