New Insights tab between Year and Customize with: - 20 insight generators producing 60+ unique insights - 5 random insights selected per section (month, year, all-time) - Categories: dominant mood, streaks, trends, positivity score, weekend vs weekday, mood swings, milestones, patterns, and more - Collapsible sections with mood-colored cards - Subscription paywall support - English and Spanish localization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1084 lines
42 KiB
Swift
1084 lines
42 KiB
Swift
//
|
|
// InsightsViewModel.swift
|
|
// Feels
|
|
//
|
|
// Created by Claude Code on 12/9/24.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
struct Insight: Identifiable {
|
|
let id = UUID()
|
|
let icon: String
|
|
let title: String
|
|
let description: String
|
|
let mood: Mood?
|
|
}
|
|
|
|
class InsightsViewModel: ObservableObject {
|
|
@Published var monthInsights: [Insight] = []
|
|
@Published var yearInsights: [Insight] = []
|
|
@Published var allTimeInsights: [Insight] = []
|
|
|
|
private let calendar = Calendar.current
|
|
|
|
init() {
|
|
generateInsights()
|
|
}
|
|
|
|
func generateInsights() {
|
|
let now = Date()
|
|
|
|
// Get date ranges
|
|
let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now))!
|
|
let yearStart = calendar.date(from: calendar.dateComponents([.year], from: now))!
|
|
let allTimeStart = Date(timeIntervalSince1970: 0)
|
|
|
|
// Fetch entries for each period
|
|
let monthEntries = PersistenceController.shared.getData(startDate: monthStart, endDate: now, includedDays: [1,2,3,4,5,6,7])
|
|
let yearEntries = PersistenceController.shared.getData(startDate: yearStart, endDate: now, includedDays: [1,2,3,4,5,6,7])
|
|
let allTimeEntries = PersistenceController.shared.getData(startDate: allTimeStart, endDate: now, includedDays: [1,2,3,4,5,6,7])
|
|
|
|
// Generate insights for each period
|
|
monthInsights = generateRandomInsights(entries: monthEntries, periodName: "this month", count: 5)
|
|
yearInsights = generateRandomInsights(entries: yearEntries, periodName: "this year", count: 5)
|
|
allTimeInsights = generateRandomInsights(entries: allTimeEntries, periodName: "all time", count: 5)
|
|
}
|
|
|
|
private func generateRandomInsights(entries: [MoodEntry], periodName: String, count: Int) -> [Insight] {
|
|
// Filter out missing/placeholder entries
|
|
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
|
|
|
guard !validEntries.isEmpty else {
|
|
return [Insight(icon: "questionmark.circle", title: "No Data Yet", description: "Start logging your moods to see insights for \(periodName).", mood: nil)]
|
|
}
|
|
|
|
// Generate all possible insights
|
|
var allInsights: [Insight] = []
|
|
|
|
// Add all insight generators
|
|
allInsights.append(contentsOf: generateMostCommonMoodInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateLeastCommonMoodInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateBestDayInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateWorstDayInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateStreakInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateTrendInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generatePositivityInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateWeekendVsWeekdayInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateMoodSwingInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateConsistencyInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateMilestoneInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateComparativeInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generatePatternInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateFunFactInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateMotivationalInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateRareMoodInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateRecentInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateMonthOfYearInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateLongestMoodRunInsights(entries: validEntries, periodName: periodName))
|
|
allInsights.append(contentsOf: generateAverageMoodInsights(entries: validEntries, periodName: periodName))
|
|
|
|
// Shuffle and pick random insights
|
|
allInsights.shuffle()
|
|
return Array(allInsights.prefix(count))
|
|
}
|
|
|
|
// MARK: - Most Common Mood
|
|
private func generateMostCommonMoodInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
let moodCounts = Dictionary(grouping: entries, by: { $0.mood }).mapValues { $0.count }
|
|
|
|
if let mostCommon = moodCounts.max(by: { $0.value < $1.value }) {
|
|
let percentage = Int((Float(mostCommon.value) / Float(entries.count)) * 100)
|
|
|
|
let variations = [
|
|
"You felt \(mostCommon.key.strValue.lowercased()) \(percentage)% of the time \(periodName).",
|
|
"\(mostCommon.key.strValue) is your go-to mood \(periodName) with \(mostCommon.value) days.",
|
|
"Your vibe \(periodName)? Definitely \(mostCommon.key.strValue.lowercased()) (\(percentage)%).",
|
|
"\(mostCommon.value) days of feeling \(mostCommon.key.strValue.lowercased()) \(periodName)!",
|
|
]
|
|
|
|
insights.append(Insight(
|
|
icon: "star.fill",
|
|
title: "Dominant Mood",
|
|
description: variations.randomElement()!,
|
|
mood: mostCommon.key
|
|
))
|
|
|
|
if percentage > 50 {
|
|
insights.append(Insight(
|
|
icon: "chart.pie.fill",
|
|
title: "Mood Majority",
|
|
description: "More than half your days \(periodName) were \(mostCommon.key.strValue.lowercased())!",
|
|
mood: mostCommon.key
|
|
))
|
|
}
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Least Common Mood
|
|
private func generateLeastCommonMoodInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
let moodCounts = Dictionary(grouping: entries, by: { $0.mood }).mapValues { $0.count }
|
|
|
|
if let leastCommon = moodCounts.filter({ $0.value > 0 }).min(by: { $0.value < $1.value }) {
|
|
let variations = [
|
|
"You rarely felt \(leastCommon.key.strValue.lowercased()) \(periodName) (only \(leastCommon.value) days).",
|
|
"\(leastCommon.key.strValue) was your rarest mood \(periodName).",
|
|
"Only \(leastCommon.value) \(leastCommon.key.strValue.lowercased()) days \(periodName) - that's rare!",
|
|
]
|
|
|
|
insights.append(Insight(
|
|
icon: "sparkle",
|
|
title: "Rare Mood",
|
|
description: variations.randomElement()!,
|
|
mood: leastCommon.key
|
|
))
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Best Day of Week
|
|
private func generateBestDayInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
let weekdayCounts = Dictionary(grouping: entries, by: { Int($0.weekDay) })
|
|
var weekdayScores: [Int: Float] = [:]
|
|
|
|
for (weekday, dayEntries) in weekdayCounts {
|
|
let avgScore = Float(dayEntries.reduce(0) { $0 + Int($1.moodValue) }) / Float(dayEntries.count)
|
|
weekdayScores[weekday] = avgScore
|
|
}
|
|
|
|
if let bestWeekday = weekdayScores.max(by: { $0.value < $1.value }) {
|
|
let weekdayName = weekdayNameFrom(weekday: bestWeekday.key)
|
|
let avgMood = moodFromScore(bestWeekday.value)
|
|
|
|
let variations = [
|
|
"\(weekdayName)s are your happiest days \(periodName)!",
|
|
"Good vibes on \(weekdayName)s - your best day \(periodName).",
|
|
"You shine brightest on \(weekdayName)s!",
|
|
"\(weekdayName) = your power day \(periodName).",
|
|
]
|
|
|
|
insights.append(Insight(
|
|
icon: "calendar.badge.checkmark",
|
|
title: "Best Day",
|
|
description: variations.randomElement()!,
|
|
mood: avgMood
|
|
))
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Worst Day of Week
|
|
private func generateWorstDayInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
let weekdayCounts = Dictionary(grouping: entries, by: { Int($0.weekDay) })
|
|
var weekdayScores: [Int: Float] = [:]
|
|
|
|
for (weekday, dayEntries) in weekdayCounts {
|
|
let avgScore = Float(dayEntries.reduce(0) { $0 + Int($1.moodValue) }) / Float(dayEntries.count)
|
|
weekdayScores[weekday] = avgScore
|
|
}
|
|
|
|
if let worstWeekday = weekdayScores.min(by: { $0.value < $1.value }) {
|
|
let weekdayName = weekdayNameFrom(weekday: worstWeekday.key)
|
|
let avgMood = moodFromScore(worstWeekday.value)
|
|
|
|
let variations = [
|
|
"\(weekdayName)s tend to be tougher for you \(periodName).",
|
|
"Watch out for \(weekdayName)s - your challenging day.",
|
|
"\(weekdayName) blues are real for you \(periodName).",
|
|
]
|
|
|
|
insights.append(Insight(
|
|
icon: "calendar.badge.exclamationmark",
|
|
title: "Challenging Day",
|
|
description: variations.randomElement()!,
|
|
mood: avgMood
|
|
))
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Streak Insights
|
|
private func generateStreakInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
let streakInfo = calculateStreaks(entries: entries)
|
|
|
|
if streakInfo.currentStreak > 0 {
|
|
let variations = [
|
|
"You've logged \(streakInfo.currentStreak) days in a row! Keep it up!",
|
|
"\(streakInfo.currentStreak)-day streak and counting!",
|
|
"On fire! \(streakInfo.currentStreak) consecutive days logged.",
|
|
]
|
|
|
|
insights.append(Insight(
|
|
icon: "flame.fill",
|
|
title: "Current Streak",
|
|
description: variations.randomElement()!,
|
|
mood: .great
|
|
))
|
|
}
|
|
|
|
if streakInfo.longestStreak > 1 {
|
|
insights.append(Insight(
|
|
icon: "trophy.fill",
|
|
title: "Longest Streak",
|
|
description: "Your record is \(streakInfo.longestStreak) consecutive days \(periodName)!",
|
|
mood: nil
|
|
))
|
|
}
|
|
|
|
if streakInfo.currentStreak == 0 {
|
|
insights.append(Insight(
|
|
icon: "arrow.clockwise",
|
|
title: "Start Fresh",
|
|
description: "Log today to start a new streak!",
|
|
mood: nil
|
|
))
|
|
}
|
|
|
|
// Good mood streaks
|
|
let goodStreaks = calculateMoodStreaks(entries: entries, moods: [.great, .good])
|
|
if goodStreaks.longest >= 3 {
|
|
insights.append(Insight(
|
|
icon: "sun.max.fill",
|
|
title: "Happiness Streak",
|
|
description: "Your longest good/great streak \(periodName) was \(goodStreaks.longest) days!",
|
|
mood: .great
|
|
))
|
|
}
|
|
|
|
// Recovery insight
|
|
let badStreaks = calculateMoodStreaks(entries: entries, moods: [.horrible, .bad])
|
|
if badStreaks.longest >= 2 && goodStreaks.longest >= 2 {
|
|
insights.append(Insight(
|
|
icon: "arrow.up.heart.fill",
|
|
title: "Resilient",
|
|
description: "You bounced back from tough times \(periodName). That takes strength!",
|
|
mood: .good
|
|
))
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Trend Insights
|
|
private func generateTrendInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
guard entries.count >= 4 else { return insights }
|
|
|
|
let sortedEntries = entries.sorted { $0.forDate! < $1.forDate! }
|
|
let halfCount = sortedEntries.count / 2
|
|
|
|
let firstHalf = Array(sortedEntries.prefix(halfCount))
|
|
let secondHalf = Array(sortedEntries.suffix(halfCount))
|
|
|
|
let firstAvg = Float(firstHalf.reduce(0) { $0 + Int($1.moodValue) }) / Float(firstHalf.count)
|
|
let secondAvg = Float(secondHalf.reduce(0) { $0 + Int($1.moodValue) }) / Float(secondHalf.count)
|
|
|
|
let diff = secondAvg - firstAvg
|
|
|
|
if diff > 0.5 {
|
|
let variations = [
|
|
"Your mood has been improving \(periodName)!",
|
|
"Things are looking up! Positive trend \(periodName).",
|
|
"You're on an upswing \(periodName)!",
|
|
"Rising vibes detected \(periodName)!",
|
|
]
|
|
insights.append(Insight(
|
|
icon: "chart.line.uptrend.xyaxis",
|
|
title: "Upward Trend",
|
|
description: variations.randomElement()!,
|
|
mood: .great
|
|
))
|
|
} else if diff < -0.5 {
|
|
let variations = [
|
|
"Your mood has dipped recently. Hang in there!",
|
|
"Tough stretch lately. Better days ahead!",
|
|
"You've been through it \(periodName). Stay strong.",
|
|
]
|
|
insights.append(Insight(
|
|
icon: "chart.line.downtrend.xyaxis",
|
|
title: "Downward Trend",
|
|
description: variations.randomElement()!,
|
|
mood: .bad
|
|
))
|
|
} else {
|
|
let variations = [
|
|
"Your mood has been steady \(periodName).",
|
|
"Consistent vibes \(periodName) - stability is good!",
|
|
"Even keel \(periodName). That's balance!",
|
|
]
|
|
insights.append(Insight(
|
|
icon: "chart.line.flattrend.xyaxis",
|
|
title: "Stable Mood",
|
|
description: variations.randomElement()!,
|
|
mood: .average
|
|
))
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Positivity Insights
|
|
private func generatePositivityInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
let positiveDays = entries.filter { [.great, .good].contains($0.mood) }.count
|
|
let negativeDays = entries.filter { [.horrible, .bad].contains($0.mood) }.count
|
|
let positiveRatio = Int((Float(positiveDays) / Float(entries.count)) * 100)
|
|
let negativeRatio = Int((Float(negativeDays) / Float(entries.count)) * 100)
|
|
|
|
if positiveRatio >= 80 {
|
|
insights.append(Insight(
|
|
icon: "sparkles",
|
|
title: "Crushing It",
|
|
description: "\(positiveRatio)% positive days \(periodName)! You're absolutely killing it!",
|
|
mood: .great
|
|
))
|
|
} else if positiveRatio >= 60 {
|
|
insights.append(Insight(
|
|
icon: "sun.max.fill",
|
|
title: "Positive Outlook",
|
|
description: "\(positiveRatio)% of your days were positive \(periodName). Nice!",
|
|
mood: .good
|
|
))
|
|
} else if positiveRatio >= 40 {
|
|
insights.append(Insight(
|
|
icon: "sun.haze.fill",
|
|
title: "Mixed Bag",
|
|
description: "About \(positiveRatio)% positive days \(periodName). Life's a balance!",
|
|
mood: .average
|
|
))
|
|
}
|
|
|
|
if negativeRatio >= 30 {
|
|
insights.append(Insight(
|
|
icon: "cloud.rain.fill",
|
|
title: "Tough Times",
|
|
description: "\(negativeRatio)% challenging days \(periodName). You're getting through it.",
|
|
mood: .bad
|
|
))
|
|
}
|
|
|
|
if negativeDays == 0 && entries.count >= 5 {
|
|
insights.append(Insight(
|
|
icon: "star.circle.fill",
|
|
title: "No Bad Days",
|
|
description: "Zero bad or horrible days \(periodName)! That's amazing!",
|
|
mood: .great
|
|
))
|
|
}
|
|
|
|
let greatDays = entries.filter { $0.mood == .great }.count
|
|
if greatDays >= 5 {
|
|
insights.append(Insight(
|
|
icon: "hands.clap.fill",
|
|
title: "Great Days Club",
|
|
description: "You had \(greatDays) great days \(periodName)!",
|
|
mood: .great
|
|
))
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Weekend vs Weekday
|
|
private func generateWeekendVsWeekdayInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
let weekendDays = entries.filter { [1, 7].contains(Int($0.weekDay)) } // Sunday = 1, Saturday = 7
|
|
let weekdayEntries = entries.filter { ![1, 7].contains(Int($0.weekDay)) }
|
|
|
|
guard !weekendDays.isEmpty && !weekdayEntries.isEmpty else { return insights }
|
|
|
|
let weekendAvg = Float(weekendDays.reduce(0) { $0 + Int($1.moodValue) }) / Float(weekendDays.count)
|
|
let weekdayAvg = Float(weekdayEntries.reduce(0) { $0 + Int($1.moodValue) }) / Float(weekdayEntries.count)
|
|
|
|
if weekendAvg > weekdayAvg + 0.5 {
|
|
insights.append(Insight(
|
|
icon: "sun.horizon.fill",
|
|
title: "Weekend Warrior",
|
|
description: "You're happier on weekends \(periodName). Living for the weekend!",
|
|
mood: .great
|
|
))
|
|
} else if weekdayAvg > weekendAvg + 0.5 {
|
|
insights.append(Insight(
|
|
icon: "briefcase.fill",
|
|
title: "Weekday Wonder",
|
|
description: "You're actually happier on weekdays \(periodName)! Work fulfills you.",
|
|
mood: .good
|
|
))
|
|
} else {
|
|
insights.append(Insight(
|
|
icon: "equal.circle.fill",
|
|
title: "Balanced Week",
|
|
description: "Weekends and weekdays feel about the same \(periodName).",
|
|
mood: .average
|
|
))
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Mood Swing Insights
|
|
private func generateMoodSwingInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
guard entries.count >= 3 else { return insights }
|
|
|
|
let sortedEntries = entries.sorted { $0.forDate! < $1.forDate! }
|
|
var swings = 0
|
|
|
|
for i in 1..<sortedEntries.count {
|
|
let diff = abs(Int(sortedEntries[i].moodValue) - Int(sortedEntries[i-1].moodValue))
|
|
if diff >= 2 {
|
|
swings += 1
|
|
}
|
|
}
|
|
|
|
let swingRate = Float(swings) / Float(entries.count - 1)
|
|
|
|
if swingRate > 0.4 {
|
|
insights.append(Insight(
|
|
icon: "waveform.path",
|
|
title: "Emotional Rollercoaster",
|
|
description: "Your moods fluctuated a lot \(periodName). Quite a ride!",
|
|
mood: nil
|
|
))
|
|
} else if swingRate < 0.1 && entries.count >= 7 {
|
|
insights.append(Insight(
|
|
icon: "minus.circle.fill",
|
|
title: "Steady Eddie",
|
|
description: "Very few mood swings \(periodName). You're emotionally steady!",
|
|
mood: .good
|
|
))
|
|
}
|
|
|
|
// Big jump detection
|
|
for i in 1..<sortedEntries.count {
|
|
let prev = sortedEntries[i-1]
|
|
let curr = sortedEntries[i]
|
|
if Int(curr.moodValue) - Int(prev.moodValue) >= 3 {
|
|
insights.append(Insight(
|
|
icon: "arrow.up.forward.circle.fill",
|
|
title: "Mood Boost",
|
|
description: "You had a big mood jump from \(prev.mood.strValue.lowercased()) to \(curr.mood.strValue.lowercased()) \(periodName)!",
|
|
mood: curr.mood
|
|
))
|
|
break
|
|
}
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Consistency Insights
|
|
private func generateConsistencyInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
let totalDaysInPeriod = calculateDaysInPeriod(periodName: periodName)
|
|
let loggedDays = entries.count
|
|
let completionRate = totalDaysInPeriod > 0 ? Int((Float(loggedDays) / Float(totalDaysInPeriod)) * 100) : 0
|
|
|
|
if completionRate >= 90 {
|
|
insights.append(Insight(
|
|
icon: "checkmark.seal.fill",
|
|
title: "Logging Pro",
|
|
description: "You logged \(completionRate)% of days \(periodName). Incredible dedication!",
|
|
mood: .great
|
|
))
|
|
} else if completionRate >= 70 {
|
|
insights.append(Insight(
|
|
icon: "checkmark.circle.fill",
|
|
title: "Consistent Logger",
|
|
description: "\(completionRate)% logging rate \(periodName). Keep it up!",
|
|
mood: .good
|
|
))
|
|
} else if completionRate >= 50 {
|
|
insights.append(Insight(
|
|
icon: "circle.dotted",
|
|
title: "Getting There",
|
|
description: "You've logged about half the days \(periodName). Room to grow!",
|
|
mood: .average
|
|
))
|
|
}
|
|
|
|
if loggedDays >= 30 {
|
|
insights.append(Insight(
|
|
icon: "30.circle.fill",
|
|
title: "30 Day Club",
|
|
description: "You've logged at least 30 days \(periodName)!",
|
|
mood: .great
|
|
))
|
|
}
|
|
|
|
if loggedDays >= 100 {
|
|
insights.append(Insight(
|
|
icon: "100.circle.fill",
|
|
title: "Century Logger",
|
|
description: "\(loggedDays) days logged \(periodName)! That's commitment!",
|
|
mood: .great
|
|
))
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Milestone Insights
|
|
private func generateMilestoneInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
let count = entries.count
|
|
|
|
let milestones = [7, 14, 21, 30, 50, 75, 100, 150, 200, 250, 300, 365, 500, 730, 1000]
|
|
for milestone in milestones {
|
|
if count >= milestone && count < milestone + 10 {
|
|
insights.append(Insight(
|
|
icon: "flag.fill",
|
|
title: "\(milestone) Days!",
|
|
description: "You've hit \(milestone) logged days \(periodName)! Milestone achieved!",
|
|
mood: .great
|
|
))
|
|
break
|
|
}
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Comparative Insights
|
|
private func generateComparativeInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
let moodCounts = Dictionary(grouping: entries, by: { $0.mood }).mapValues { $0.count }
|
|
|
|
let greatCount = moodCounts[.great] ?? 0
|
|
let goodCount = moodCounts[.good] ?? 0
|
|
let averageCount = moodCounts[.average] ?? 0
|
|
let badCount = moodCounts[.bad] ?? 0
|
|
let horribleCount = moodCounts[.horrible] ?? 0
|
|
|
|
if greatCount > goodCount + averageCount + badCount + horribleCount {
|
|
insights.append(Insight(
|
|
icon: "crown.fill",
|
|
title: "Great Majority",
|
|
description: "More great days than all other moods combined \(periodName)!",
|
|
mood: .great
|
|
))
|
|
}
|
|
|
|
if greatCount > 0 && horribleCount > 0 {
|
|
let ratio = Float(greatCount) / Float(horribleCount)
|
|
if ratio >= 5 {
|
|
insights.append(Insight(
|
|
icon: "scale.3d",
|
|
title: "Positive Ratio",
|
|
description: "\(Int(ratio))x more great days than horrible days \(periodName)!",
|
|
mood: .great
|
|
))
|
|
}
|
|
}
|
|
|
|
if goodCount + greatCount > badCount + horribleCount {
|
|
let positive = goodCount + greatCount
|
|
let negative = badCount + horribleCount
|
|
if negative > 0 {
|
|
insights.append(Insight(
|
|
icon: "hand.thumbsup.fill",
|
|
title: "Net Positive",
|
|
description: "\(positive) positive days vs \(negative) negative days \(periodName).",
|
|
mood: .good
|
|
))
|
|
}
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Pattern Insights
|
|
private func generatePatternInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
// Monday blues check
|
|
let mondays = entries.filter { Int($0.weekDay) == 2 }
|
|
if !mondays.isEmpty {
|
|
let mondayAvg = Float(mondays.reduce(0) { $0 + Int($1.moodValue) }) / Float(mondays.count)
|
|
if mondayAvg < 2 {
|
|
insights.append(Insight(
|
|
icon: "moon.zzz.fill",
|
|
title: "Monday Blues",
|
|
description: "Mondays are rough for you \(periodName). Hang in there!",
|
|
mood: .bad
|
|
))
|
|
} else if mondayAvg >= 3.5 {
|
|
insights.append(Insight(
|
|
icon: "sun.and.horizon.fill",
|
|
title: "Monday Motivation",
|
|
description: "You actually like Mondays! Starting the week strong \(periodName).",
|
|
mood: .good
|
|
))
|
|
}
|
|
}
|
|
|
|
// Friday feels
|
|
let fridays = entries.filter { Int($0.weekDay) == 6 }
|
|
if !fridays.isEmpty {
|
|
let fridayAvg = Float(fridays.reduce(0) { $0 + Int($1.moodValue) }) / Float(fridays.count)
|
|
if fridayAvg >= 3.5 {
|
|
insights.append(Insight(
|
|
icon: "party.popper.fill",
|
|
title: "TGIF Energy",
|
|
description: "Fridays are your happy place \(periodName)!",
|
|
mood: .great
|
|
))
|
|
}
|
|
}
|
|
|
|
// Hump day
|
|
let wednesdays = entries.filter { Int($0.weekDay) == 4 }
|
|
if !wednesdays.isEmpty {
|
|
let wedAvg = Float(wednesdays.reduce(0) { $0 + Int($1.moodValue) }) / Float(wednesdays.count)
|
|
if wedAvg >= 3 {
|
|
insights.append(Insight(
|
|
icon: "figure.walk",
|
|
title: "Hump Day Hero",
|
|
description: "You crush Wednesdays \(periodName). Midweek champion!",
|
|
mood: .good
|
|
))
|
|
}
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Fun Fact Insights
|
|
private func generateFunFactInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
let greatDays = entries.filter { $0.mood == .great }.count
|
|
let goodDays = entries.filter { $0.mood == .good }.count
|
|
let averageDays = entries.filter { $0.mood == .average }.count
|
|
|
|
if greatDays >= 7 {
|
|
insights.append(Insight(
|
|
icon: "face.smiling.inverse",
|
|
title: "Week of Great",
|
|
description: "You've had at least a full week's worth of great days \(periodName)!",
|
|
mood: .great
|
|
))
|
|
}
|
|
|
|
if averageDays >= entries.count / 2 && entries.count >= 10 {
|
|
insights.append(Insight(
|
|
icon: "figure.stand",
|
|
title: "The Average Joe",
|
|
description: "Most of your days \(periodName) were... average. And that's okay!",
|
|
mood: .average
|
|
))
|
|
}
|
|
|
|
// Calculate "great day streak potential"
|
|
let recentEntries = entries.sorted { $0.forDate! > $1.forDate! }.prefix(7)
|
|
let recentGreat = recentEntries.filter { $0.mood == .great }.count
|
|
if recentGreat >= 3 {
|
|
insights.append(Insight(
|
|
icon: "bolt.fill",
|
|
title: "On a Roll",
|
|
description: "\(recentGreat) great days in your last 7 entries! You're vibing!",
|
|
mood: .great
|
|
))
|
|
}
|
|
|
|
if goodDays + greatDays >= 20 {
|
|
insights.append(Insight(
|
|
icon: "heart.fill",
|
|
title: "20+ Happy Days",
|
|
description: "Over 20 good or great days \(periodName). Life is good!",
|
|
mood: .great
|
|
))
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Motivational Insights
|
|
private func generateMotivationalInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
let avgMood = Float(entries.reduce(0) { $0 + Int($1.moodValue) }) / Float(entries.count)
|
|
|
|
if avgMood >= 3.5 {
|
|
let messages = [
|
|
"You're doing amazing! Keep spreading those good vibes.",
|
|
"Your positivity \(periodName) is inspiring!",
|
|
"Whatever you're doing, keep doing it!",
|
|
]
|
|
insights.append(Insight(
|
|
icon: "hands.sparkles.fill",
|
|
title: "Keep Shining",
|
|
description: messages.randomElement()!,
|
|
mood: .great
|
|
))
|
|
} else if avgMood >= 2.5 {
|
|
let messages = [
|
|
"You're handling life well \(periodName). Stay balanced!",
|
|
"Steady progress \(periodName). You've got this!",
|
|
"Balance is key, and you're finding it.",
|
|
]
|
|
insights.append(Insight(
|
|
icon: "figure.mind.and.body",
|
|
title: "Stay Centered",
|
|
description: messages.randomElement()!,
|
|
mood: .good
|
|
))
|
|
} else {
|
|
let messages = [
|
|
"Tough times don't last, but tough people do.",
|
|
"Every day is a new chance. You've got this!",
|
|
"Tracking is the first step to understanding. Keep going.",
|
|
"Better days are coming. Believe in yourself!",
|
|
]
|
|
insights.append(Insight(
|
|
icon: "heart.circle.fill",
|
|
title: "Hang In There",
|
|
description: messages.randomElement()!,
|
|
mood: .average
|
|
))
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Rare Mood Insights
|
|
private func generateRareMoodInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
let moodCounts = Dictionary(grouping: entries, by: { $0.mood }).mapValues { $0.count }
|
|
|
|
// Check for missing moods
|
|
let allMoods: [Mood] = [.great, .good, .average, .bad, .horrible]
|
|
let missingMoods = allMoods.filter { moodCounts[$0] == nil || moodCounts[$0] == 0 }
|
|
|
|
if missingMoods.contains(.horrible) && entries.count >= 10 {
|
|
insights.append(Insight(
|
|
icon: "xmark.shield.fill",
|
|
title: "No Horrible Days",
|
|
description: "Not a single horrible day \(periodName)! That's worth celebrating!",
|
|
mood: .great
|
|
))
|
|
}
|
|
|
|
if missingMoods.contains(.bad) && entries.count >= 10 {
|
|
insights.append(Insight(
|
|
icon: "shield.fill",
|
|
title: "Bad-Free Zone",
|
|
description: "Zero bad days \(periodName). You're in a good place!",
|
|
mood: .great
|
|
))
|
|
}
|
|
|
|
if missingMoods.count == 0 && entries.count >= 10 {
|
|
insights.append(Insight(
|
|
icon: "paintpalette.fill",
|
|
title: "Full Spectrum",
|
|
description: "You've experienced every mood \(periodName). That's being human!",
|
|
mood: nil
|
|
))
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Recent Insights
|
|
private func generateRecentInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
let sortedEntries = entries.sorted { $0.forDate! > $1.forDate! }
|
|
guard let mostRecent = sortedEntries.first else { return insights }
|
|
|
|
let daysSinceLastEntry = calendar.dateComponents([.day], from: mostRecent.forDate!, to: Date()).day ?? 0
|
|
|
|
if daysSinceLastEntry == 0 {
|
|
insights.append(Insight(
|
|
icon: "clock.badge.checkmark.fill",
|
|
title: "Logged Today",
|
|
description: "You already logged today! Great job staying on top of it.",
|
|
mood: mostRecent.mood
|
|
))
|
|
} else if daysSinceLastEntry == 1 {
|
|
insights.append(Insight(
|
|
icon: "clock.fill",
|
|
title: "Yesterday's Log",
|
|
description: "Last entry was yesterday. Don't forget today!",
|
|
mood: nil
|
|
))
|
|
} else if daysSinceLastEntry <= 3 {
|
|
insights.append(Insight(
|
|
icon: "clock.arrow.circlepath",
|
|
title: "Recent Logger",
|
|
description: "Last entry was \(daysSinceLastEntry) days ago. Time to catch up!",
|
|
mood: nil
|
|
))
|
|
}
|
|
|
|
// Last 3 entries mood
|
|
if sortedEntries.count >= 3 {
|
|
let last3 = Array(sortedEntries.prefix(3))
|
|
let last3Avg = Float(last3.reduce(0) { $0 + Int($1.moodValue) }) / 3.0
|
|
let avgMood = moodFromScore(last3Avg)
|
|
|
|
if last3Avg >= 3.5 {
|
|
insights.append(Insight(
|
|
icon: "arrow.up.circle.fill",
|
|
title: "Recent High",
|
|
description: "Your last 3 entries have been great! Momentum is building.",
|
|
mood: avgMood
|
|
))
|
|
} else if last3Avg <= 1.5 {
|
|
insights.append(Insight(
|
|
icon: "arrow.down.circle.fill",
|
|
title: "Recent Low",
|
|
description: "Last few entries have been tough. Tomorrow's a new day!",
|
|
mood: avgMood
|
|
))
|
|
}
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Month of Year Insights
|
|
private func generateMonthOfYearInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
guard periodName == "this year" || periodName == "all time" else { return insights }
|
|
|
|
let monthGroups = Dictionary(grouping: entries) { entry -> Int in
|
|
calendar.component(.month, from: entry.forDate!)
|
|
}
|
|
|
|
var monthScores: [Int: Float] = [:]
|
|
for (month, monthEntries) in monthGroups {
|
|
if monthEntries.count >= 3 {
|
|
let avgScore = Float(monthEntries.reduce(0) { $0 + Int($1.moodValue) }) / Float(monthEntries.count)
|
|
monthScores[month] = avgScore
|
|
}
|
|
}
|
|
|
|
if let bestMonth = monthScores.max(by: { $0.value < $1.value }) {
|
|
let monthName = DateFormatter().monthSymbols[bestMonth.key - 1]
|
|
insights.append(Insight(
|
|
icon: "calendar.circle.fill",
|
|
title: "Best Month",
|
|
description: "\(monthName) is your happiest month \(periodName)!",
|
|
mood: moodFromScore(bestMonth.value)
|
|
))
|
|
}
|
|
|
|
if let worstMonth = monthScores.min(by: { $0.value < $1.value }) {
|
|
let monthName = DateFormatter().monthSymbols[worstMonth.key - 1]
|
|
insights.append(Insight(
|
|
icon: "calendar.badge.minus",
|
|
title: "Challenging Month",
|
|
description: "\(monthName) tends to be tougher for you \(periodName).",
|
|
mood: moodFromScore(worstMonth.value)
|
|
))
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Longest Mood Run
|
|
private func generateLongestMoodRunInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
for mood in [Mood.great, .good, .average, .bad, .horrible] {
|
|
let streaks = calculateMoodStreaks(entries: entries, moods: [mood])
|
|
if streaks.longest >= 3 {
|
|
let adjective: String
|
|
switch mood {
|
|
case .great: adjective = "amazing"
|
|
case .good: adjective = "solid"
|
|
case .average: adjective = "steady"
|
|
case .bad: adjective = "rough"
|
|
case .horrible: adjective = "tough"
|
|
default: adjective = ""
|
|
}
|
|
|
|
insights.append(Insight(
|
|
icon: "repeat.circle.fill",
|
|
title: "\(mood.strValue) Streak",
|
|
description: "Longest \(mood.strValue.lowercased()) streak \(periodName): \(streaks.longest) \(adjective) days in a row.",
|
|
mood: mood
|
|
))
|
|
}
|
|
}
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Average Mood Insights
|
|
private func generateAverageMoodInsights(entries: [MoodEntry], periodName: String) -> [Insight] {
|
|
var insights: [Insight] = []
|
|
|
|
let avgMood = Float(entries.reduce(0) { $0 + Int($1.moodValue) }) / Float(entries.count)
|
|
let roundedAvg = moodFromScore(avgMood)
|
|
|
|
let avgDescriptions: [Mood: [String]] = [
|
|
.great: [
|
|
"Your average mood \(periodName) is great! Living your best life.",
|
|
"On average, you're feeling great \(periodName). Amazing!",
|
|
],
|
|
.good: [
|
|
"Your average mood \(periodName) is good. Solid vibes!",
|
|
"Generally good vibes \(periodName). Keep it up!",
|
|
],
|
|
.average: [
|
|
"Your average mood \(periodName) is... average. Balanced!",
|
|
"Right in the middle \(periodName). That's life!",
|
|
],
|
|
.bad: [
|
|
"Your average mood \(periodName) has been lower. Hang in there.",
|
|
"Tougher times \(periodName), but tracking helps.",
|
|
],
|
|
.horrible: [
|
|
"It's been a hard \(periodName). You're brave for tracking.",
|
|
"Rough \(periodName). Remember: this too shall pass.",
|
|
],
|
|
]
|
|
|
|
if let descriptions = avgDescriptions[roundedAvg] {
|
|
insights.append(Insight(
|
|
icon: "number.circle.fill",
|
|
title: "Average Mood",
|
|
description: descriptions.randomElement()!,
|
|
mood: roundedAvg
|
|
))
|
|
}
|
|
|
|
// Mood score as a number
|
|
let score = String(format: "%.1f", avgMood)
|
|
insights.append(Insight(
|
|
icon: "gauge.with.needle.fill",
|
|
title: "Mood Score",
|
|
description: "Your mood score \(periodName): \(score) out of 4.0",
|
|
mood: roundedAvg
|
|
))
|
|
|
|
return insights
|
|
}
|
|
|
|
// MARK: - Helper Functions
|
|
|
|
private func calculateStreaks(entries: [MoodEntry]) -> (currentStreak: Int, longestStreak: Int) {
|
|
let sortedEntries = entries.sorted { $0.forDate! > $1.forDate! }
|
|
guard !sortedEntries.isEmpty else { return (0, 0) }
|
|
|
|
var currentStreak = 0
|
|
var longestStreak = 0
|
|
var tempStreak = 1
|
|
|
|
let today = calendar.startOfDay(for: Date())
|
|
if let mostRecent = sortedEntries.first?.forDate,
|
|
calendar.isDate(mostRecent, inSameDayAs: today) || calendar.isDate(mostRecent, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
|
|
currentStreak = 1
|
|
var checkDate = calendar.date(byAdding: .day, value: -1, to: mostRecent)!
|
|
|
|
for entry in sortedEntries.dropFirst() {
|
|
if let entryDate = entry.forDate,
|
|
calendar.isDate(entryDate, inSameDayAs: checkDate) {
|
|
currentStreak += 1
|
|
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate)!
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
for i in 1..<sortedEntries.count {
|
|
if let currentDate = sortedEntries[i].forDate,
|
|
let previousDate = sortedEntries[i-1].forDate {
|
|
let dayDiff = calendar.dateComponents([.day], from: currentDate, to: previousDate).day ?? 0
|
|
if dayDiff == 1 {
|
|
tempStreak += 1
|
|
} else {
|
|
longestStreak = max(longestStreak, tempStreak)
|
|
tempStreak = 1
|
|
}
|
|
}
|
|
}
|
|
longestStreak = max(longestStreak, tempStreak)
|
|
|
|
return (currentStreak, longestStreak)
|
|
}
|
|
|
|
private func calculateMoodStreaks(entries: [MoodEntry], moods: [Mood]) -> (current: Int, longest: Int) {
|
|
let sortedEntries = entries.sorted { $0.forDate! < $1.forDate! }
|
|
guard !sortedEntries.isEmpty else { return (0, 0) }
|
|
|
|
var currentStreak = 0
|
|
var longestStreak = 0
|
|
var tempStreak = 0
|
|
|
|
for entry in sortedEntries {
|
|
if moods.contains(entry.mood) {
|
|
tempStreak += 1
|
|
longestStreak = max(longestStreak, tempStreak)
|
|
} else {
|
|
tempStreak = 0
|
|
}
|
|
}
|
|
|
|
// Check if current streak is at the end
|
|
let reversedEntries = sortedEntries.reversed()
|
|
for entry in reversedEntries {
|
|
if moods.contains(entry.mood) {
|
|
currentStreak += 1
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
return (currentStreak, longestStreak)
|
|
}
|
|
|
|
private func calculateDaysInPeriod(periodName: String) -> Int {
|
|
let now = Date()
|
|
switch periodName {
|
|
case "this month":
|
|
let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now))!
|
|
return calendar.dateComponents([.day], from: monthStart, to: now).day ?? 0 + 1
|
|
case "this year":
|
|
let yearStart = calendar.date(from: calendar.dateComponents([.year], from: now))!
|
|
return calendar.dateComponents([.day], from: yearStart, to: now).day ?? 0 + 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
private func weekdayNameFrom(weekday: Int) -> String {
|
|
let weekdays = ["", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
|
return weekdays[min(max(weekday, 1), 7)]
|
|
}
|
|
|
|
private func moodFromScore(_ score: Float) -> Mood {
|
|
switch score {
|
|
case 3.5...: return .great
|
|
case 2.5..<3.5: return .good
|
|
case 1.5..<2.5: return .average
|
|
case 0.5..<1.5: return .bad
|
|
default: return .horrible
|
|
}
|
|
}
|
|
}
|