From f37b811ab356fd674db13da80e4ec64970c832d0 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 10 Dec 2025 00:11:07 -0600 Subject: [PATCH] Add Insights tab with 60+ randomized analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Shared/FeelsApp.swift | 3 +- Shared/Views/InsightsView/InsightsView.swift | 232 ++++ .../InsightsView/InsightsViewModel.swift | 1083 +++++++++++++++++ Shared/Views/MainTabView.swift | 17 +- en.lproj/Localizable.strings | 1 + es.lproj/Localizable.strings | 9 +- 6 files changed, 1335 insertions(+), 10 deletions(-) create mode 100644 Shared/Views/InsightsView/InsightsView.swift create mode 100644 Shared/Views/InsightsView/InsightsViewModel.swift diff --git a/Shared/FeelsApp.swift b/Shared/FeelsApp.swift index fe6792a..6f2b3e9 100644 --- a/Shared/FeelsApp.swift +++ b/Shared/FeelsApp.swift @@ -34,8 +34,9 @@ struct FeelsApp: App { // build these here so when tints and other things get updated the views / their data dont // have to get redrawn#imageLiteral(resourceName: "simulator_screenshot_0017B4DC-100B-42A3-A406-9019704AE275.png") MainTabView(dayView: DayView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: false)), - monthView: MonthView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: true)), + monthView: MonthView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: true)), yearView: YearView(viewModel: YearViewModel()), + insightsView: InsightsView(), customizeView: CustomizeView()) .environment(\.managedObjectContext, persistenceController.viewContext) .environmentObject(iapManager) diff --git a/Shared/Views/InsightsView/InsightsView.swift b/Shared/Views/InsightsView/InsightsView.swift new file mode 100644 index 0000000..6221bbd --- /dev/null +++ b/Shared/Views/InsightsView/InsightsView.swift @@ -0,0 +1,232 @@ +// +// InsightsView.swift +// Feels +// +// Created by Claude Code on 12/9/24. +// + +import SwiftUI + +struct InsightsView: View { + @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system + @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor + @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default + @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome + + @StateObject private var viewModel = InsightsViewModel() + @EnvironmentObject var iapManager: IAPManager + @State private var showSubscriptionStore = false + + var body: some View { + ZStack { + ScrollView { + VStack(spacing: 20) { + // Header + HStack { + Text("Insights") + .font(.largeTitle.bold()) + .foregroundColor(textColor) + Spacer() + } + .padding(.horizontal) + + // This Month Section + InsightsSectionView( + title: "This Month", + icon: "calendar", + insights: viewModel.monthInsights, + textColor: textColor, + moodTint: moodTint, + imagePack: imagePack, + theme: theme + ) + + // This Year Section + InsightsSectionView( + title: "This Year", + icon: "calendar.badge.clock", + insights: viewModel.yearInsights, + textColor: textColor, + moodTint: moodTint, + imagePack: imagePack, + theme: theme + ) + + // All Time Section + InsightsSectionView( + title: "All Time", + icon: "infinity", + insights: viewModel.allTimeInsights, + textColor: textColor, + moodTint: moodTint, + imagePack: imagePack, + theme: theme + ) + } + .padding(.vertical) + .padding(.bottom, 100) + } + .disabled(iapManager.shouldShowPaywall) + + if iapManager.shouldShowPaywall { + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + showSubscriptionStore = true + } + + VStack { + Spacer() + Button { + showSubscriptionStore = true + } label: { + Text(String(localized: "subscription_required_button")) + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(RoundedRectangle(cornerRadius: 10).fill(Color.pink)) + } + .padding() + } + } + } + .sheet(isPresented: $showSubscriptionStore) { + FeelsSubscriptionStoreView() + } + .background( + theme.currentTheme.bg + .edgesIgnoringSafeArea(.all) + ) + .onAppear { + EventLogger.log(event: "show_insights_view") + viewModel.generateInsights() + } + .padding(.top) + } +} + +// MARK: - Insights Section View +struct InsightsSectionView: View { + let title: String + let icon: String + let insights: [Insight] + let textColor: Color + let moodTint: MoodTints + let imagePack: MoodImages + let theme: Theme + + @State private var isExpanded = true + + var body: some View { + VStack(spacing: 0) { + // Section Header + Button(action: { withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() } }) { + HStack { + Image(systemName: icon) + .font(.title3) + .foregroundColor(textColor.opacity(0.7)) + + Text(title) + .font(.title2.bold()) + .foregroundColor(textColor) + + Spacer() + + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption.weight(.semibold)) + .foregroundColor(textColor.opacity(0.5)) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + .buttonStyle(.plain) + + // Insights List (collapsible) + if isExpanded { + VStack(spacing: 12) { + ForEach(insights) { insight in + InsightCardView( + insight: insight, + textColor: textColor, + moodTint: moodTint, + imagePack: imagePack + ) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .background( + RoundedRectangle(cornerRadius: 16) + .fill(theme.currentTheme.secondaryBGColor) + ) + .padding(.horizontal) + } +} + +// MARK: - Insight Card View +struct InsightCardView: View { + let insight: Insight + let textColor: Color + let moodTint: MoodTints + let imagePack: MoodImages + + private var accentColor: Color { + if let mood = insight.mood { + return moodTint.color(forMood: mood) + } + return textColor.opacity(0.6) + } + + var body: some View { + HStack(alignment: .top, spacing: 14) { + // Icon + ZStack { + Circle() + .fill(accentColor.opacity(0.15)) + .frame(width: 44, height: 44) + + if let mood = insight.mood { + imagePack.icon(forMood: mood) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 22, height: 22) + .foregroundColor(accentColor) + } else { + Image(systemName: insight.icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(accentColor) + } + } + + // Text Content + VStack(alignment: .leading, spacing: 4) { + Text(insight.title) + .font(.subheadline.weight(.semibold)) + .foregroundColor(textColor) + + Text(insight.description) + .font(.subheadline) + .foregroundColor(textColor.opacity(0.7)) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(accentColor.opacity(0.08)) + ) + } +} + +struct InsightsView_Previews: PreviewProvider { + static var previews: some View { + InsightsView() + .environmentObject(IAPManager()) + } +} diff --git a/Shared/Views/InsightsView/InsightsViewModel.swift b/Shared/Views/InsightsView/InsightsViewModel.swift new file mode 100644 index 0000000..5b00cfd --- /dev/null +++ b/Shared/Views/InsightsView/InsightsViewModel.swift @@ -0,0 +1,1083 @@ +// +// 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..= 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..= 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.. (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 + } + } +} diff --git a/Shared/Views/MainTabView.swift b/Shared/Views/MainTabView.swift index 00ee10c..e1840a0 100644 --- a/Shared/Views/MainTabView.swift +++ b/Shared/Views/MainTabView.swift @@ -19,25 +19,31 @@ struct MainTabView: View { let dayView: DayView let monthView: MonthView let yearView: YearView + let insightsView: InsightsView let customizeView: CustomizeView - + var body: some View { return TabView { dayView .tabItem { Label(String(localized: "content_view_tab_main"), systemImage: "list.dash") } - + monthView .tabItem { Label(String(localized: "content_view_tab_month"), systemImage: "calendar") } - + yearView .tabItem { Label(String(localized: "content_view_tab_filter"), systemImage: "line.3.horizontal.decrease.circle") } - + + insightsView + .tabItem { + Label(String(localized: "content_view_tab_insights"), systemImage: "lightbulb.fill") + } + customizeView .tabItem { Label(String(localized: "content_view_tab_customize"), systemImage: "pencil") @@ -81,8 +87,9 @@ struct MainTabView: View { struct MainTabView_Previews: PreviewProvider { static var previews: some View { MainTabView(dayView: DayView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: false)), - monthView: MonthView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: true)), + monthView: MonthView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: true)), yearView: YearView(viewModel: YearViewModel()), + insightsView: InsightsView(), customizeView: CustomizeView()) } } diff --git a/en.lproj/Localizable.strings b/en.lproj/Localizable.strings index 6c2266b..a756e9b 100644 --- a/en.lproj/Localizable.strings +++ b/en.lproj/Localizable.strings @@ -37,6 +37,7 @@ "content_view_tab_main" = "Day"; "content_view_tab_month" = "Month"; "content_view_tab_filter" = "Year"; +"content_view_tab_insights" = "Insights"; "content_view_tab_customize" = "Customize"; "content_view_fill_in_missing_entry" = "Update %@"; "content_view_fill_in_missing_entry_cancel" = "Cancel"; diff --git a/es.lproj/Localizable.strings b/es.lproj/Localizable.strings index debc016..fe9599b 100644 --- a/es.lproj/Localizable.strings +++ b/es.lproj/Localizable.strings @@ -34,10 +34,11 @@ "add_mood_header_view_title_yesterday" = "How was yesterday?"; "add_mood_header_view_title" = "How was %@?"; -"content_view_tab_main" = "Day"; -"content_view_tab_month" = "Month"; -"content_view_tab_filter" = "Year"; -"content_view_tab_customize" = "Customize"; +"content_view_tab_main" = "Día"; +"content_view_tab_month" = "Mes"; +"content_view_tab_filter" = "Año"; +"content_view_tab_insights" = "Análisis"; +"content_view_tab_customize" = "Personalizar"; "content_view_fill_in_missing_entry" = "Update %@"; "content_view_fill_in_missing_entry_cancel" = "Cancel"; "content_view_delete_entry" = "Delete this rating";