Files
Reflect/Shared/Views/InsightsView/InsightsViewModel.swift
Trey t f37b811ab3 Add Insights tab with 60+ randomized analytics
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>
2025-12-10 00:11:07 -06:00

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
}
}
}