diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index a23f57b..9b2502a 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -657,7 +657,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.6; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -693,7 +693,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 18.6; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Shared/Models/AIInsight.swift b/Shared/Models/AIInsight.swift new file mode 100644 index 0000000..99a9f51 --- /dev/null +++ b/Shared/Models/AIInsight.swift @@ -0,0 +1,68 @@ +// +// AIInsight.swift +// Feels +// +// Created by Claude Code on 12/13/24. +// + +import Foundation +import FoundationModels + +/// Represents a single AI-generated insight using @Generable for structured LLM output +@Generable +struct AIInsight: Equatable { + @Guide(description: "A brief, engaging title for the insight (3-6 words)") + var title: String + + @Guide(description: "A detailed, personalized description of the insight (1-2 sentences)") + var description: String + + @Guide(description: "The type of insight: pattern (observed trend), advice (recommendation), prediction (future forecast), achievement (milestone), or trend (direction analysis)") + var insightType: String + + @Guide(description: "The primary mood associated with this insight: great, good, average, bad, horrible, or none") + var associatedMood: String + + @Guide(description: "SF Symbol name for the insight icon (e.g., star.fill, chart.line.uptrend.xyaxis, heart.fill)") + var iconName: String + + @Guide(description: "Sentiment of the insight: positive, neutral, or negative") + var sentiment: String +} + +/// Container for period-specific insights - the LLM generates this structure +@Generable +struct AIInsightsResponse: Equatable { + @Guide(description: "Array of exactly 5 diverse insights covering patterns, advice, and predictions") + var insights: [AIInsight] +} + +// MARK: - Conversion to App's Insight Model + +extension AIInsight { + /// Converts AI-generated insight to the app's existing Insight model + func toInsight() -> Insight { + let mood: Mood? = switch associatedMood.lowercased() { + case "great": .great + case "good": .good + case "average": .average + case "bad": .bad + case "horrible": .horrible + default: nil + } + + return Insight( + icon: iconName, + title: title, + description: description, + mood: mood + ) + } +} + +extension AIInsightsResponse { + /// Converts all AI insights to app's Insight models + func toInsights() -> [Insight] { + insights.map { $0.toInsight() } + } +} diff --git a/Shared/Services/FoundationModelsInsightService.swift b/Shared/Services/FoundationModelsInsightService.swift new file mode 100644 index 0000000..c6c1c76 --- /dev/null +++ b/Shared/Services/FoundationModelsInsightService.swift @@ -0,0 +1,219 @@ +// +// FoundationModelsInsightService.swift +// Feels +// +// Created by Claude Code on 12/13/24. +// + +import Foundation +import FoundationModels + +/// Error types for insight generation +enum InsightGenerationError: Error, LocalizedError { + case modelUnavailable(reason: String) + case insufficientData + case generationFailed(underlying: Error) + case invalidResponse + + var errorDescription: String? { + switch self { + case .modelUnavailable(let reason): + return "AI insights unavailable: \(reason)" + case .insufficientData: + return "Not enough mood data to generate insights" + case .generationFailed(let error): + return "Failed to generate insights: \(error.localizedDescription)" + case .invalidResponse: + return "Unable to parse AI response" + } + } +} + +/// Service responsible for generating AI-powered mood insights using Apple's Foundation Models +@MainActor +class FoundationModelsInsightService: ObservableObject { + + // MARK: - Published State + + @Published private(set) var isAvailable: Bool = false + @Published private(set) var isGenerating: Bool = false + @Published private(set) var lastError: InsightGenerationError? + + // MARK: - Dependencies + + private let summarizer = MoodDataSummarizer() + + // MARK: - Cache + + private var cachedInsights: [String: (insights: [Insight], timestamp: Date)] = [:] + private let cacheValidityDuration: TimeInterval = 3600 // 1 hour + + // MARK: - Initialization + + init() { + checkAvailability() + } + + /// Check if Foundation Models is available on this device + func checkAvailability() { + let model = SystemLanguageModel.default + switch model.availability { + case .available: + isAvailable = true + case .unavailable(let reason): + isAvailable = false + lastError = .modelUnavailable(reason: describeUnavailability(reason)) + @unknown default: + isAvailable = false + lastError = .modelUnavailable(reason: "Unknown availability status") + } + } + + private func describeUnavailability(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String { + switch reason { + case .deviceNotEligible: + return "This device doesn't support Apple Intelligence" + case .appleIntelligenceNotEnabled: + return "Apple Intelligence is not enabled in Settings" + case .modelNotReady: + return "The AI model is still downloading" + @unknown default: + return "AI features are not available" + } + } + + /// Creates a new session for each request to allow concurrent generation + private func createSession() -> LanguageModelSession { + LanguageModelSession(instructions: systemInstructions) + } + + // MARK: - System Instructions + + private var systemInstructions: String { + let personalityPack = UserDefaultsStore.personalityPackable() + + switch personalityPack { + case .Default: + return defaultSystemInstructions + case .Rude: + return rudeSystemInstructions + } + } + + private var defaultSystemInstructions: String { + """ + You are a supportive mood analyst for the Feels app. Analyze mood data and provide warm, actionable insights. + + Style: Encouraging, empathetic, concise (1-2 sentences per insight). Reference specific data. + + SF Symbols: star.fill, sun.max.fill, heart.fill, chart.line.uptrend.xyaxis, trophy.fill, calendar, leaf.fill, sparkles + """ + } + + private var rudeSystemInstructions: String { + """ + You are a brutally honest, sarcastic mood analyst. Think: judgmental friend meets disappointed therapist. + + Style: Backhanded compliments, dramatic disappointment, weaponize their own data against them. Use "Oh honey," "Congratulations," and "Shocking" sarcastically. Mock patterns, not pain. + + Examples: "THREE whole good days? Trophy's in the mail." / "Mondays destroy you. Revolutionary." / "Your mood tanks Sundays. Almost like you hate your job." + + SF Symbols: eye.fill, trophy.fill, flame.fill, exclamationmark.triangle.fill, theatermasks.fill, crown.fill + """ + } + + // MARK: - Insight Generation + + /// Generate AI-powered insights for the given mood entries + /// - Parameters: + /// - entries: Array of mood entries to analyze + /// - periodName: The time period name (e.g., "this month", "this year", "all time") + /// - count: Number of insights to generate (default 5) + /// - Returns: Array of Insight objects + func generateInsights( + for entries: [MoodEntryModel], + periodName: String, + count: Int = 5 + ) async throws -> [Insight] { + // Check cache first + if let cached = cachedInsights[periodName], + Date().timeIntervalSince(cached.timestamp) < cacheValidityDuration { + return cached.insights + } + + guard isAvailable else { + throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available") + } + + // Create a new session for this request to allow concurrent generation + let session = createSession() + + // Filter valid entries + let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) } + + guard !validEntries.isEmpty else { + throw InsightGenerationError.insufficientData + } + + isGenerating = true + defer { isGenerating = false } + + // Prepare data summary + let summary = summarizer.summarize(entries: validEntries, periodName: periodName) + let prompt = buildPrompt(from: summary, count: count) + + do { + let response = try await session.respond( + to: prompt, + generating: AIInsightsResponse.self + ) + + let insights = response.content.insights.map { $0.toInsight() } + + // Cache results + cachedInsights[periodName] = (insights, Date()) + + return insights + } catch { + // Log detailed error for debugging + print("❌ AI Insight generation failed for '\(periodName)': \(error)") + print(" Error type: \(type(of: error))") + print(" Localized: \(error.localizedDescription)") + + lastError = .generationFailed(underlying: error) + throw lastError! + } + } + + // MARK: - Prompt Construction + + private func buildPrompt(from summary: MoodDataSummary, count: Int) -> String { + let dataSection = summarizer.toPromptString(summary) + + return """ + Analyze this mood data and generate \(count) insights: + + \(dataSection) + + Include: 1 pattern, 1 advice, 1 prediction, and other varied insights. Reference specific data points. + """ + } + + // MARK: - Cache Management + + /// Invalidate all cached insights + func invalidateCache() { + cachedInsights.removeAll() + } + + /// Invalidate cached insights for a specific period + func invalidateCache(for periodName: String) { + cachedInsights.removeValue(forKey: periodName) + } + + /// Check if insights are cached for a period + func hasCachedInsights(for periodName: String) -> Bool { + guard let cached = cachedInsights[periodName] else { return false } + return Date().timeIntervalSince(cached.timestamp) < cacheValidityDuration + } +} diff --git a/Shared/Services/MoodDataSummarizer.swift b/Shared/Services/MoodDataSummarizer.swift new file mode 100644 index 0000000..a02415e --- /dev/null +++ b/Shared/Services/MoodDataSummarizer.swift @@ -0,0 +1,389 @@ +// +// MoodDataSummarizer.swift +// Feels +// +// Created by Claude Code on 12/13/24. +// + +import Foundation + +/// Summary of mood data for a specific time period, formatted for AI consumption +struct MoodDataSummary { + let periodName: String + let totalEntries: Int + let dateRange: String + + // Mood distribution + let moodCounts: [String: Int] + let moodPercentages: [String: Int] + let averageMoodScore: Double + + // Temporal patterns + let weekdayAverages: [String: Double] + let weekendAverage: Double + let weekdayAverage: Double + let bestDayOfWeek: String + let worstDayOfWeek: String + + // Trends + let recentTrend: String + let trendMagnitude: Double + + // Streaks + let currentLoggingStreak: Int + let longestLoggingStreak: Int + let longestPositiveStreak: Int + let longestNegativeStreak: Int + + // Variability + let moodSwingCount: Int + let moodStabilityScore: Double + + // Recent context + let last7DaysAverage: Double + let last7DaysMoods: [String] + + // Notable observations + let hasAllMoodTypes: Bool + let missingMoodTypes: [String] +} + +/// Transforms raw MoodEntryModel data into AI-optimized summaries +class MoodDataSummarizer { + private let calendar = Calendar.current + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter + }() + + // MARK: - Main Summarization + + func summarize(entries: [MoodEntryModel], periodName: String) -> MoodDataSummary { + let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) } + + guard !validEntries.isEmpty else { + return emptyDataSummary(periodName: periodName) + } + + let sortedEntries = validEntries.sorted { $0.forDate < $1.forDate } + + // Calculate all metrics + let moodDistribution = calculateMoodDistribution(entries: validEntries) + let temporalPatterns = calculateTemporalPatterns(entries: validEntries) + let trend = calculateTrend(entries: sortedEntries) + let streaks = calculateStreaks(entries: sortedEntries) + let variability = calculateVariability(entries: sortedEntries) + let recentContext = calculateRecentContext(entries: sortedEntries) + let moodTypes = calculateMoodTypes(entries: validEntries) + + // Format date range + let dateRange = formatDateRange(entries: sortedEntries) + + return MoodDataSummary( + periodName: periodName, + totalEntries: validEntries.count, + dateRange: dateRange, + moodCounts: moodDistribution.counts, + moodPercentages: moodDistribution.percentages, + averageMoodScore: moodDistribution.average, + weekdayAverages: temporalPatterns.weekdayAverages, + weekendAverage: temporalPatterns.weekendAverage, + weekdayAverage: temporalPatterns.weekdayAverage, + bestDayOfWeek: temporalPatterns.bestDay, + worstDayOfWeek: temporalPatterns.worstDay, + recentTrend: trend.direction, + trendMagnitude: trend.magnitude, + currentLoggingStreak: streaks.current, + longestLoggingStreak: streaks.longest, + longestPositiveStreak: streaks.longestPositive, + longestNegativeStreak: streaks.longestNegative, + moodSwingCount: variability.swingCount, + moodStabilityScore: variability.stabilityScore, + last7DaysAverage: recentContext.average, + last7DaysMoods: recentContext.moods, + hasAllMoodTypes: moodTypes.hasAll, + missingMoodTypes: moodTypes.missing + ) + } + + // MARK: - Mood Distribution + + private func calculateMoodDistribution(entries: [MoodEntryModel]) -> (counts: [String: Int], percentages: [String: Int], average: Double) { + var counts: [String: Int] = [:] + var totalScore = 0 + + for entry in entries { + let moodName = entry.mood.widgetDisplayName.lowercased() + counts[moodName, default: 0] += 1 + totalScore += Int(entry.moodValue) + } + + var percentages: [String: Int] = [:] + for (mood, count) in counts { + percentages[mood] = Int((Double(count) / Double(entries.count)) * 100) + } + + let average = Double(totalScore) / Double(entries.count) + + return (counts, percentages, average) + } + + // MARK: - Temporal Patterns + + private func calculateTemporalPatterns(entries: [MoodEntryModel]) -> (weekdayAverages: [String: Double], weekendAverage: Double, weekdayAverage: Double, bestDay: String, worstDay: String) { + let weekdayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + var weekdayTotals: [Int: (total: Int, count: Int)] = [:] + + for entry in entries { + let weekday = Int(entry.weekDay) + let current = weekdayTotals[weekday, default: (0, 0)] + weekdayTotals[weekday] = (current.total + Int(entry.moodValue), current.count + 1) + } + + var weekdayAverages: [String: Double] = [:] + var bestDay = "Monday" + var worstDay = "Monday" + var bestAvg = -1.0 + var worstAvg = 5.0 + + for (weekday, data) in weekdayTotals { + let avg = Double(data.total) / Double(data.count) + let dayName = weekdayNames[weekday - 1] + weekdayAverages[dayName] = avg + + if avg > bestAvg { + bestAvg = avg + bestDay = dayName + } + if avg < worstAvg { + worstAvg = avg + worstDay = dayName + } + } + + // Weekend vs weekday + let weekendEntries = entries.filter { [1, 7].contains(Int($0.weekDay)) } + let weekdayEntries = entries.filter { ![1, 7].contains(Int($0.weekDay)) } + + let weekendAvg = weekendEntries.isEmpty ? 0 : Double(weekendEntries.reduce(0) { $0 + Int($1.moodValue) }) / Double(weekendEntries.count) + let weekdayAvg = weekdayEntries.isEmpty ? 0 : Double(weekdayEntries.reduce(0) { $0 + Int($1.moodValue) }) / Double(weekdayEntries.count) + + return (weekdayAverages, weekendAvg, weekdayAvg, bestDay, worstDay) + } + + // MARK: - Trend Analysis + + private func calculateTrend(entries: [MoodEntryModel]) -> (direction: String, magnitude: Double) { + guard entries.count >= 4 else { + return ("stable", 0.0) + } + + let halfCount = entries.count / 2 + let firstHalf = Array(entries.prefix(halfCount)) + let secondHalf = Array(entries.suffix(halfCount)) + + let firstAvg = Double(firstHalf.reduce(0) { $0 + Int($1.moodValue) }) / Double(firstHalf.count) + let secondAvg = Double(secondHalf.reduce(0) { $0 + Int($1.moodValue) }) / Double(secondHalf.count) + + let diff = secondAvg - firstAvg + + let direction: String + if diff > 0.5 { + direction = "improving" + } else if diff < -0.5 { + direction = "declining" + } else { + direction = "stable" + } + + return (direction, abs(diff)) + } + + // MARK: - Streak Calculations + + private func calculateStreaks(entries: [MoodEntryModel]) -> (current: Int, longest: Int, longestPositive: Int, longestNegative: Int) { + let sortedByDateDesc = entries.sorted { $0.forDate > $1.forDate } + + // Current logging streak + var currentStreak = 0 + let today = calendar.startOfDay(for: Date()) + + if let mostRecent = sortedByDateDesc.first?.forDate { + let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! + + if calendar.isDate(mostRecent, inSameDayAs: today) || calendar.isDate(mostRecent, inSameDayAs: yesterday) { + currentStreak = 1 + var checkDate = calendar.date(byAdding: .day, value: -1, to: mostRecent)! + + for entry in sortedByDateDesc.dropFirst() { + if calendar.isDate(entry.forDate, inSameDayAs: checkDate) { + currentStreak += 1 + checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate)! + } else { + break + } + } + } + } + + // Longest logging streak + var longestStreak = 1 + var tempStreak = 1 + let sortedByDateAsc = entries.sorted { $0.forDate < $1.forDate } + + for i in 1.. Int { + var longest = 0 + var current = 0 + + for entry in entries { + if moods.contains(entry.mood) { + current += 1 + longest = max(longest, current) + } else { + current = 0 + } + } + + return longest + } + + // MARK: - Variability + + private func calculateVariability(entries: [MoodEntryModel]) -> (swingCount: Int, stabilityScore: Double) { + guard entries.count >= 2 else { + return (0, 1.0) + } + + var swings = 0 + for i in 1..= 2 { + swings += 1 + } + } + + let swingRate = Double(swings) / Double(entries.count - 1) + let stabilityScore = 1.0 - min(swingRate, 1.0) + + return (swings, stabilityScore) + } + + // MARK: - Recent Context + + private func calculateRecentContext(entries: [MoodEntryModel]) -> (average: Double, moods: [String]) { + let recentEntries = entries.suffix(7) + + guard !recentEntries.isEmpty else { + return (0, []) + } + + let average = Double(recentEntries.reduce(0) { $0 + Int($1.moodValue) }) / Double(recentEntries.count) + let moods = recentEntries.map { $0.mood.widgetDisplayName } + + return (average, moods) + } + + // MARK: - Mood Types + + private func calculateMoodTypes(entries: [MoodEntryModel]) -> (hasAll: Bool, missing: [String]) { + let allMoods: Set = [.great, .good, .average, .bad, .horrible] + let presentMoods = Set(entries.map { $0.mood }) + + let missing = allMoods.subtracting(presentMoods).map { $0.widgetDisplayName } + let hasAll = missing.isEmpty + + return (hasAll, missing) + } + + // MARK: - Helpers + + private func formatDateRange(entries: [MoodEntryModel]) -> String { + guard let first = entries.first, let last = entries.last else { + return "No data" + } + + let startDate = dateFormatter.string(from: first.forDate) + let endDate = dateFormatter.string(from: last.forDate) + + return "\(startDate) - \(endDate)" + } + + private func emptyDataSummary(periodName: String) -> MoodDataSummary { + MoodDataSummary( + periodName: periodName, + totalEntries: 0, + dateRange: "No data", + moodCounts: [:], + moodPercentages: [:], + averageMoodScore: 0, + weekdayAverages: [:], + weekendAverage: 0, + weekdayAverage: 0, + bestDayOfWeek: "N/A", + worstDayOfWeek: "N/A", + recentTrend: "stable", + trendMagnitude: 0, + currentLoggingStreak: 0, + longestLoggingStreak: 0, + longestPositiveStreak: 0, + longestNegativeStreak: 0, + moodSwingCount: 0, + moodStabilityScore: 1.0, + last7DaysAverage: 0, + last7DaysMoods: [], + hasAllMoodTypes: false, + missingMoodTypes: ["great", "good", "average", "bad", "horrible"] + ) + } + + // MARK: - Prompt String Generation + + /// Generates a concise prompt string optimized for Apple's 4096 token context limit + func toPromptString(_ summary: MoodDataSummary) -> String { + // Compact format to stay under token limit + var lines: [String] = [] + + lines.append("Period: \(summary.periodName), \(summary.totalEntries) entries, avg \(String(format: "%.1f", summary.averageMoodScore))/4") + + // Mood distribution - compact + let moodDist = summary.moodPercentages.sorted { $0.key < $1.key } + .map { "\($0.key): \($0.value)%" } + .joined(separator: ", ") + lines.append("Moods: \(moodDist)") + + // Day patterns - only best/worst + lines.append("Best day: \(summary.bestDayOfWeek), Worst: \(summary.worstDayOfWeek)") + lines.append("Weekend avg: \(String(format: "%.1f", summary.weekendAverage)), Weekday avg: \(String(format: "%.1f", summary.weekdayAverage))") + + // Trends + lines.append("Trend: \(summary.recentTrend), Last 7 days avg: \(String(format: "%.1f", summary.last7DaysAverage))") + + // Streaks - compact + lines.append("Streaks - Current: \(summary.currentLoggingStreak)d, Longest: \(summary.longestLoggingStreak)d, Best positive: \(summary.longestPositiveStreak)d, Worst negative: \(summary.longestNegativeStreak)d") + + // Stability + lines.append("Stability: \(String(format: "%.0f", summary.moodStabilityScore * 100))%, Mood swings: \(summary.moodSwingCount)") + + return lines.joined(separator: "\n") + } +} diff --git a/Shared/Views/InsightsView/InsightsView.swift b/Shared/Views/InsightsView/InsightsView.swift index 5d0782c..c2a45b8 100644 --- a/Shared/Views/InsightsView/InsightsView.swift +++ b/Shared/Views/InsightsView/InsightsView.swift @@ -28,6 +28,27 @@ struct InsightsView: View { .font(.system(size: 28, weight: .bold, design: .rounded)) .foregroundColor(textColor) Spacer() + + // AI badge + if viewModel.isAIAvailable { + HStack(spacing: 4) { + Image(systemName: "sparkles") + .font(.system(size: 12, weight: .medium)) + Text("AI") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + LinearGradient( + colors: [.purple, .blue], + startPoint: .leading, + endPoint: .trailing + ) + ) + .clipShape(Capsule()) + } } .padding(.horizontal) @@ -36,6 +57,7 @@ struct InsightsView: View { title: "This Month", icon: "calendar", insights: viewModel.monthInsights, + loadingState: viewModel.monthLoadingState, textColor: textColor, moodTint: moodTint, imagePack: imagePack, @@ -47,6 +69,7 @@ struct InsightsView: View { title: "This Year", icon: "calendar.badge.clock", insights: viewModel.yearInsights, + loadingState: viewModel.yearLoadingState, textColor: textColor, moodTint: moodTint, imagePack: imagePack, @@ -58,6 +81,7 @@ struct InsightsView: View { title: "All Time", icon: "infinity", insights: viewModel.allTimeInsights, + loadingState: viewModel.allTimeLoadingState, textColor: textColor, moodTint: moodTint, imagePack: imagePack, @@ -67,6 +91,11 @@ struct InsightsView: View { .padding(.vertical) .padding(.bottom, 100) } + .refreshable { + viewModel.refreshInsights() + // Small delay to show refresh animation + try? await Task.sleep(nanoseconds: 500_000_000) + } .disabled(iapManager.shouldShowPaywall) if iapManager.shouldShowPaywall { @@ -108,10 +137,12 @@ struct InsightsView: View { } // MARK: - Insights Section View + struct InsightsSectionView: View { let title: String let icon: String let insights: [Insight] + let loadingState: InsightLoadingState let textColor: Color let moodTint: MoodTints let imagePack: MoodImages @@ -132,6 +163,13 @@ struct InsightsSectionView: View { .font(.system(size: 20, weight: .bold)) .foregroundColor(textColor) + // Loading indicator in header + if loadingState == .loading { + ProgressView() + .scaleEffect(0.7) + .padding(.leading, 4) + } + Spacer() Image(systemName: isExpanded ? "chevron.up" : "chevron.down") @@ -145,20 +183,61 @@ struct InsightsSectionView: View { // Insights List (collapsible) if isExpanded { - VStack(spacing: 10) { - ForEach(insights) { insight in - InsightCardView( - insight: insight, - textColor: textColor, - moodTint: moodTint, - imagePack: imagePack, - colorScheme: colorScheme - ) + switch loadingState { + case .loading: + // Skeleton loading view + VStack(spacing: 10) { + ForEach(0..<3, id: \.self) { _ in + InsightSkeletonView(colorScheme: colorScheme) + } } + .padding(.horizontal, 16) + .padding(.bottom, 16) + .transition(.opacity) + + case .error: + // Show insights (which contain error message) with error styling + VStack(spacing: 10) { + ForEach(insights) { insight in + InsightCardView( + insight: insight, + textColor: textColor, + moodTint: moodTint, + imagePack: imagePack, + colorScheme: colorScheme + ) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + .transition(.opacity.combined(with: .move(edge: .top))) + + case .loaded, .idle: + // Normal insights display with staggered animation + VStack(spacing: 10) { + ForEach(Array(insights.enumerated()), id: \.element.id) { index, insight in + InsightCardView( + insight: insight, + textColor: textColor, + moodTint: moodTint, + imagePack: imagePack, + colorScheme: colorScheme + ) + .transition(.asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.95)).combined(with: .offset(y: 10)), + removal: .opacity + )) + .animation( + .spring(response: 0.4, dampingFraction: 0.8) + .delay(Double(index) * 0.05), + value: insights.count + ) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + .transition(.opacity.combined(with: .move(edge: .top))) } - .padding(.horizontal, 16) - .padding(.bottom, 16) - .transition(.opacity.combined(with: .move(edge: .top))) } } .background( @@ -166,10 +245,60 @@ struct InsightsSectionView: View { .fill(colorScheme == .dark ? Color(.systemGray6) : .white) ) .padding(.horizontal) + .animation(.easeInOut(duration: 0.2), value: isExpanded) + } +} + +// MARK: - Skeleton Loading View + +struct InsightSkeletonView: View { + let colorScheme: ColorScheme + @State private var isAnimating = false + + var body: some View { + HStack(alignment: .top, spacing: 14) { + // Icon placeholder + Circle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 44, height: 44) + + // Text placeholders + VStack(alignment: .leading, spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.3)) + .frame(width: 120, height: 16) + + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.2)) + .frame(maxWidth: .infinity) + .frame(height: 14) + + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.2)) + .frame(width: 180, height: 14) + } + + Spacer() + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6)) + ) + .opacity(isAnimating ? 0.6 : 1.0) + .animation( + .easeInOut(duration: 0.8) + .repeatForever(autoreverses: true), + value: isAnimating + ) + .onAppear { + isAnimating = true + } } } // MARK: - Insight Card View + struct InsightCardView: View { let insight: Insight let textColor: Color diff --git a/Shared/Views/InsightsView/InsightsViewModel.swift b/Shared/Views/InsightsView/InsightsViewModel.swift index 38512e6..8531370 100644 --- a/Shared/Views/InsightsView/InsightsViewModel.swift +++ b/Shared/Views/InsightsView/InsightsViewModel.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI +/// Represents a single insight to display in the UI struct Insight: Identifiable { let id = UUID() let icon: String @@ -16,19 +17,59 @@ struct Insight: Identifiable { let mood: Mood? } +/// Loading state for each insight section +enum InsightLoadingState: Equatable { + case idle + case loading + case loaded + case error(String) +} + +/// ViewModel for the Insights tab - uses Apple Foundation Models for AI-powered insights @MainActor class InsightsViewModel: ObservableObject { + + // MARK: - Published Properties + @Published var monthInsights: [Insight] = [] @Published var yearInsights: [Insight] = [] @Published var allTimeInsights: [Insight] = [] + @Published var monthLoadingState: InsightLoadingState = .idle + @Published var yearLoadingState: InsightLoadingState = .idle + @Published var allTimeLoadingState: InsightLoadingState = .idle + + @Published var isAIAvailable: Bool = false + + // MARK: - Dependencies + + private let insightService = FoundationModelsInsightService() private let calendar = Calendar.current + // MARK: - Initialization + init() { + isAIAvailable = insightService.isAvailable + } + + // MARK: - Public Methods + + /// Generate insights for all time periods + func generateInsights() { + Task { + await generateAllInsights() + } + } + + /// Force refresh all insights (invalidates cache) + func refreshInsights() { + insightService.invalidateCache() generateInsights() } - func generateInsights() { + // MARK: - Private Methods + + private func generateAllInsights() async { let now = Date() // Get date ranges @@ -37,1051 +78,93 @@ class InsightsViewModel: ObservableObject { let allTimeStart = Date(timeIntervalSince1970: 0) // Fetch entries for each period - let monthEntries = DataController.shared.getData(startDate: monthStart, endDate: now, includedDays: [1,2,3,4,5,6,7]) - let yearEntries = DataController.shared.getData(startDate: yearStart, endDate: now, includedDays: [1,2,3,4,5,6,7]) - let allTimeEntries = DataController.shared.getData(startDate: allTimeStart, endDate: now, includedDays: [1,2,3,4,5,6,7]) + let monthEntries = DataController.shared.getData(startDate: monthStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7]) + let yearEntries = DataController.shared.getData(startDate: yearStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7]) + let allTimeEntries = DataController.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) + // Generate insights concurrently for all three periods + await withTaskGroup(of: Void.self) { group in + group.addTask { @MainActor in + await self.generatePeriodInsights( + entries: monthEntries, + periodName: "this month", + updateState: { self.monthLoadingState = $0 }, + updateInsights: { self.monthInsights = $0 } + ) + } + + group.addTask { @MainActor in + await self.generatePeriodInsights( + entries: yearEntries, + periodName: "this year", + updateState: { self.yearLoadingState = $0 }, + updateInsights: { self.yearInsights = $0 } + ) + } + + group.addTask { @MainActor in + await self.generatePeriodInsights( + entries: allTimeEntries, + periodName: "all time", + updateState: { self.allTimeLoadingState = $0 }, + updateInsights: { self.allTimeInsights = $0 } + ) + } + } } - private func generateRandomInsights(entries: [MoodEntryModel], periodName: String, count: Int) -> [Insight] { - // Filter out missing/placeholder entries + private func generatePeriodInsights( + entries: [MoodEntryModel], + periodName: String, + updateState: @escaping (InsightLoadingState) -> Void, + updateInsights: @escaping ([Insight]) -> Void + ) async { + // Filter valid entries let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) } + // Handle empty data case 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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)!", + updateInsights([Insight( + icon: "questionmark.circle", + title: "No Data Yet", + description: "Start logging your moods to see insights for \(periodName).", mood: nil - )) + )]) + updateState(.loaded) + return } - if streakInfo.currentStreak == 0 { - insights.append(Insight( - icon: "arrow.clockwise", - title: "Start Fresh", - description: "Log today to start a new streak!", + // Check if AI is available + guard isAIAvailable else { + updateInsights([Insight( + icon: "brain.head.profile", + title: "AI Unavailable", + description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.", mood: nil - )) + )]) + updateState(.error("AI not available")) + return } - // 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 - )) - } + updateState(.loading) - // 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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!", + do { + let insights = try await insightService.generateInsights( + for: validEntries, + periodName: periodName, + count: 5 + ) + updateInsights(insights) + updateState(.loaded) + } catch { + // On error, provide a helpful message + updateInsights([Insight( + icon: "exclamationmark.triangle", + title: "Insights Unavailable", + description: "Unable to generate AI insights right now. Please try again later.", 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel], 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: [MoodEntryModel]) -> (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()) - guard let mostRecent = sortedEntries.first?.forDate, - let yesterday = calendar.date(byAdding: .day, value: -1, to: today) else { - return (0, 0) - } - - if calendar.isDate(mostRecent, inSameDayAs: today) || calendar.isDate(mostRecent, inSameDayAs: yesterday) { - currentStreak = 1 - var checkDate = calendar.date(byAdding: .day, value: -1, to: mostRecent) ?? mostRecent - - for entry in sortedEntries.dropFirst() { - let entryDate = entry.forDate - if calendar.isDate(entryDate, inSameDayAs: checkDate) { - currentStreak += 1 - checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? 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 + )]) + updateState(.error(error.localizedDescription)) } } }