import Foundation import Combine import CoreData @MainActor class DashboardViewModel: ObservableObject { // MARK: - Published Properties @Published var portfolioSummary: PortfolioSummary = .empty @Published var monthlySummary: MonthlySummary = .empty @Published var categoryMetrics: [CategoryMetrics] = [] @Published var categoryEvolutionData: [CategoryEvolutionPoint] = [] @Published var recentSnapshots: [Snapshot] = [] @Published var sourcesNeedingUpdate: [InvestmentSource] = [] @Published var latestPortfolioChange: PortfolioChange = .empty @Published var isLoading = false @Published var errorMessage: String? @Published var selectedAccount: Account? @Published var showAllAccounts = true @Published var showingPaywall = false // MARK: - Chart Data @Published var evolutionData: [(date: Date, value: Decimal)] = [] @Published var updateStreak: Int = 0 // MARK: - Portfolio Forecast @Published var portfolioForecast: PortfolioForecast? // MARK: - Dependencies private let categoryRepository: CategoryRepository private let sourceRepository: InvestmentSourceRepository private let snapshotRepository: SnapshotRepository private let calculationService: CalculationService private let predictionEngine: PredictionEngine private var cancellables = Set() private var isRefreshing = false private var refreshQueued = false private var refreshTask: Task? private let maxHistoryMonths = 60 // MARK: - Performance: Caching private var cachedFilteredSources: [InvestmentSource]? private var cachedSourcesHash: Int = 0 private var lastAccountId: UUID? private var lastShowAllAccounts: Bool = true // MARK: - Initialization init( categoryRepository: CategoryRepository? = nil, sourceRepository: InvestmentSourceRepository? = nil, snapshotRepository: SnapshotRepository? = nil, calculationService: CalculationService? = nil ) { self.categoryRepository = categoryRepository ?? CategoryRepository() self.sourceRepository = sourceRepository ?? InvestmentSourceRepository() self.snapshotRepository = snapshotRepository ?? SnapshotRepository() self.calculationService = calculationService ?? .shared self.predictionEngine = .shared setupObservers() loadData() } // MARK: - Setup private func setupObservers() { // Performance: Combine multiple publishers to reduce redundant refresh calls // Use dropFirst to avoid initial trigger, and debounce to coalesce rapid changes Publishers.Merge( categoryRepository.$categories.map { _ in () }, sourceRepository.$sources.map { _ in () } ) .dropFirst(2) // Skip initial values from both publishers .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .sink { [weak self] _ in self?.invalidateCache() self?.refreshData() } .store(in: &cancellables) // Observe Core Data changes with coalescing NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange) .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .compactMap { [weak self] notification -> Void? in guard let self, self.isRelevantChange(notification) else { return nil } return () } .sink { [weak self] _ in self?.invalidateCache() self?.refreshData() } .store(in: &cancellables) } private func isRelevantChange(_ notification: Notification) -> Bool { guard let info = notification.userInfo else { return false } let keys: [String] = [ NSInsertedObjectsKey, NSUpdatedObjectsKey, NSDeletedObjectsKey, NSRefreshedObjectsKey ] for key in keys { if let objects = info[key] as? Set { if objects.contains(where: { $0 is Snapshot || $0 is InvestmentSource || $0 is Category }) { return true } } } return false } // MARK: - Data Loading func loadData() { isLoading = true errorMessage = nil queueRefresh(updateLoadingFlag: true) } func refreshData() { queueRefresh(updateLoadingFlag: false) } private func queueRefresh(updateLoadingFlag: Bool) { refreshQueued = true if updateLoadingFlag { isLoading = true } guard !isRefreshing else { return } isRefreshing = true // Cancel any previous refresh task to avoid stale updates refreshTask?.cancel() refreshTask = Task { [weak self] in guard let self else { return } while self.refreshQueued && !Task.isCancelled { self.refreshQueued = false await self.refreshAllData() } self.isRefreshing = false if updateLoadingFlag { self.isLoading = false } } } /// Cancel any ongoing background tasks - call when view disappears func cancelPendingTasks() { refreshTask?.cancel() refreshTask = nil } private func refreshAllData() async { let categories = categoryRepository.categories let sources = filteredSources() let allSnapshots = filteredSnapshots(for: sources) // Calculate portfolio summary portfolioSummary = calculationService.calculatePortfolioSummary( from: sources, snapshots: allSnapshots ) monthlySummary = calculationService.calculateMonthlySummary( sources: sources, snapshots: allSnapshots ) // Calculate category metrics categoryMetrics = calculationService.calculateCategoryMetrics( for: categories, sources: sources, totalPortfolioValue: portfolioSummary.totalValue ).sorted { $0.totalValue > $1.totalValue } // Get recent snapshots recentSnapshots = Array(allSnapshots.prefix(10)) // Get sources needing update let accountFilter = showAllAccounts ? nil : selectedAccount sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter) // Calculate evolution data for chart (only completed monthly check-ins) let completedSnapshots = filterSnapshotsForCharts( sources: sources, snapshots: allSnapshots ) updateEvolutionData(from: completedSnapshots, categories: categories) latestPortfolioChange = calculateLatestCheckInChange( sources: sources, snapshots: allSnapshots, fallback: evolutionData ) // Calculate portfolio forecast updatePortfolioForecast() // Compute update streak updateStreak = computeStreak(from: evolutionData) // Log screen view FirebaseService.shared.logScreenView(screenName: "Dashboard") } private func filteredSources() -> [InvestmentSource] { let safeSelectedAccountId = selectedAccount?.safeId // Performance: Cache filtered sources to avoid repeated filtering let currentHash = sourceRepository.sources.count let accountChanged = lastAccountId != safeSelectedAccountId || lastShowAllAccounts != showAllAccounts if !accountChanged && cachedFilteredSources != nil && cachedSourcesHash == currentHash { return cachedFilteredSources! } lastAccountId = safeSelectedAccountId lastShowAllAccounts = showAllAccounts cachedSourcesHash = currentHash if showAllAccounts || safeSelectedAccountId == nil { cachedFilteredSources = sourceRepository.sources } else { cachedFilteredSources = sourceRepository.sources.filter { $0.account?.id == safeSelectedAccountId } } return cachedFilteredSources! } /// Invalidates cached data when underlying data changes private func invalidateCache() { cachedFilteredSources = nil cachedSourcesHash = 0 } private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] { let sourceIds = sources.compactMap { $0.id } return snapshotRepository.fetchSnapshots( for: sourceIds, months: maxHistoryMonths ) } private func updateEvolutionData(from snapshots: [Snapshot], categories: [Category]) { let summary = calculateEvolutionSummary(from: snapshots) let categoriesWithData = Set(summary.categoryTotals.keys) let categoryLookup = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) }) evolutionData = summary.evolutionData categoryEvolutionData = summary.categorySeries.flatMap { entry in entry.valuesByCategory.compactMap { categoryId, value in guard categoriesWithData.contains(categoryId), let category = categoryLookup[categoryId] else { return nil } return CategoryEvolutionPoint( date: entry.date, categoryName: category.name, colorHex: category.colorHex, value: value ) } } } private struct EvolutionSummary { let evolutionData: [(date: Date, value: Decimal)] let categorySeries: [(date: Date, valuesByCategory: [UUID: Decimal])] let categoryTotals: [UUID: Decimal] } private func calculateEvolutionSummary(from snapshots: [Snapshot]) -> EvolutionSummary { guard !snapshots.isEmpty else { return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:]) } let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) return DateComponents(year: components.year, month: components.month) } var evolution: [(date: Date, value: Decimal)] = [] evolution.reserveCapacity(groupedByMonth.count) var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = [] series.reserveCapacity(groupedByMonth.count) var categoryTotals: [UUID: Decimal] = [:] for (key, monthSnapshots) in groupedByMonth { var latestBySource: [UUID: Snapshot] = [:] for snapshot in monthSnapshots { guard let sourceId = snapshot.source?.id else { continue } if let existing = latestBySource[sourceId] { if snapshot.date > existing.date { latestBySource[sourceId] = snapshot } } else { latestBySource[sourceId] = snapshot } } var total: Decimal = 0 var valuesByCategory: [UUID: Decimal] = [:] for snapshot in latestBySource.values { let value = snapshot.decimalValue total += value if let categoryId = snapshot.source?.category?.id { valuesByCategory[categoryId, default: 0] += value categoryTotals[categoryId, default: 0] += value } } let date = Calendar.current.date(from: key) ?? Date() evolution.append((date: date, value: total)) series.append((date: date, valuesByCategory: valuesByCategory)) } evolution.sort { $0.date < $1.date } series.sort { $0.date < $1.date } return EvolutionSummary( evolutionData: evolution, categorySeries: series, categoryTotals: categoryTotals ) } private func completedMonthKeys( sources: [InvestmentSource], snapshots: [Snapshot], after cutoff: Date ) -> Set { let sourceIds = Set(sources.compactMap { $0.id }) guard !sourceIds.isEmpty else { return [] } let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) return DateComponents(year: components.year, month: components.month) } var completed: Set = [] completed.reserveCapacity(groupedByMonth.count) for (key, monthSnapshots) in groupedByMonth { guard let monthDate = Calendar.current.date(from: key) else { continue } guard monthDate > cutoff else { continue } guard MonthlyCheckInStore.completionDate(for: monthDate) != nil else { continue } let monthSourceIds = Set(monthSnapshots.compactMap { $0.source?.id }) if sourceIds.isSubset(of: monthSourceIds) { completed.insert(key) } } return completed } private func filterSnapshotsForCharts( sources: [InvestmentSource], snapshots: [Snapshot] ) -> [Snapshot] { guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else { return snapshots } let completedMonthsAfter = completedMonthKeys( sources: sources, snapshots: snapshots, after: lastCompleted ) return snapshots.filter { snapshot in let monthDate = snapshot.date.startOfMonth if let completionDate = MonthlyCheckInStore.completionDate(for: monthDate), snapshot.date > completionDate { return false } if monthDate <= lastCompleted { return true } let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) let key = DateComponents(year: components.year, month: components.month) return completedMonthsAfter.contains(key) } } private func calculateLatestChange(from data: [(date: Date, value: Decimal)]) -> PortfolioChange { guard data.count >= 2 else { return PortfolioChange(absolute: 0, percentage: 0, label: "since last check-in") } let last = data[data.count - 1] let previous = data[data.count - 2] let absolute = last.value - previous.value let percentage = previous.value > 0 ? NSDecimalNumber(decimal: absolute / previous.value).doubleValue * 100 : 0 return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last check-in") } private func calculateLatestCheckInChange( sources: [InvestmentSource], snapshots: [Snapshot], fallback: [(date: Date, value: Decimal)] ) -> PortfolioChange { let completedMonths = MonthlyCheckInStore.allEntries() .compactMap { entry -> Date? in entry.entry.completionDate != nil ? entry.date.startOfMonth : nil } .sorted() guard completedMonths.count >= 2 else { return calculateLatestChange(from: fallback) } let lastMonth = completedMonths[completedMonths.count - 1] let previousMonth = completedMonths[completedMonths.count - 2] let lastCompletion = MonthlyCheckInStore.completionDate(for: lastMonth) ?? lastMonth.endOfMonth let previousCompletion = MonthlyCheckInStore.completionDate(for: previousMonth) ?? previousMonth.endOfMonth let lastValue = totalPortfolioValue(asOf: lastCompletion, sources: sources, snapshots: snapshots) let previousValue = totalPortfolioValue(asOf: previousCompletion, sources: sources, snapshots: snapshots) let absolute = lastValue - previousValue let percentage = previousValue > 0 ? NSDecimalNumber(decimal: absolute / previousValue).doubleValue * 100 : 0 return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last check-in") } private func totalPortfolioValue( asOf date: Date, sources: [InvestmentSource], snapshots: [Snapshot] ) -> Decimal { let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id } var total = Decimal.zero for source in sources { let sourceId = source.id guard let sourceSnapshots = snapshotsBySource[sourceId] else { continue } if let latest = sourceSnapshots .filter({ $0.date <= date }) .max(by: { $0.date < $1.date }) { total += latest.decimalValue } } return total } private func updatePortfolioForecast() { // Need at least 3 data points for meaningful forecast guard evolutionData.count >= 3 else { portfolioForecast = nil return } let currentValue = evolutionData.last?.value ?? 0 // Use the evolution data to predict 12 months ahead // Create "virtual snapshots" from evolution data for the prediction engine let virtualSnapshots = evolutionData.map { dataPoint -> VirtualSnapshot in VirtualSnapshot(date: dataPoint.date, value: dataPoint.value) } // Calculate using Holt-Winters or linear trend based on data volatility let values = virtualSnapshots.map { NSDecimalNumber(decimal: $0.value).doubleValue } guard values.count >= 3 else { portfolioForecast = nil return } // Calculate monthly growth rate from recent data let recentData = Array(virtualSnapshots.suffix(min(12, virtualSnapshots.count))) guard let firstPoint = recentData.first, let lastPoint = recentData.last, firstPoint.value > 0 else { portfolioForecast = nil return } let monthsBetween = max(1, firstPoint.date.monthsBetween(lastPoint.date)) let totalGrowth = lastPoint.value - firstPoint.value let monthlyGrowth = totalGrowth / Decimal(monthsBetween) // Project 12 months ahead let forecastValue = currentValue + (monthlyGrowth * 12) // Calculate confidence interval based on volatility let volatility = calculatePortfolioVolatility(from: values) let confidenceWidth = NSDecimalNumber(decimal: currentValue).doubleValue * volatility * 0.5 portfolioForecast = PortfolioForecast( currentValue: currentValue, forecastValue: forecastValue, forecastDate: Calendar.current.date(byAdding: .month, value: 12, to: Date()) ?? Date(), confidenceLower: Decimal(max(0, NSDecimalNumber(decimal: forecastValue).doubleValue - confidenceWidth)), confidenceUpper: forecastValue + Decimal(confidenceWidth), monthlyGrowthRate: monthlyGrowth, annualizedGrowthRate: calculateAnnualizedGrowth(from: virtualSnapshots) ) } private func calculatePortfolioVolatility(from values: [Double]) -> Double { guard values.count >= 2 else { return 0 } var returns: [Double] = [] for i in 1.. Decimal { guard let first = snapshots.first, let last = snapshots.last, first.value > 0 else { return 0 } let monthsBetween = max(1, first.date.monthsBetween(last.date)) let totalReturn = (last.value - first.value) / first.value // Annualize: (1 + total_return)^(12/months) - 1 let monthlyReturn = totalReturn / Decimal(monthsBetween) let annualized = monthlyReturn * 12 return annualized } private func computeStreak(from evolutionData: [(date: Date, value: Decimal)]) -> Int { guard !evolutionData.isEmpty else { return 0 } let calendar = Calendar.current // Group into (year, month) set let monthSet: Set = Set(evolutionData.map { point in let comps = calendar.dateComponents([.year, .month], from: point.date) return DateComponents(year: comps.year, month: comps.month) }) // Get sorted unique months descending let sortedMonths = monthSet .compactMap { comps -> Date? in calendar.date(from: comps) } .sorted(by: >) guard let mostRecent = sortedMonths.first else { return 0 } var streak = 1 var current = mostRecent for i in 1.. String? { let target = goal.targetDecimal let lastValue = evolutionData.last?.value ?? currentValue if currentValue >= target { return "Goal reached. Keep it steady." } guard let months = estimateMonthsToGoal(target: target, lastValue: lastValue) else { return "Keep going. Consistency pays off." } if months == 0 { return "Almost there. One more check-in." } let baseDate = evolutionData.last?.date ?? Date() let estimatedDate = Calendar.current.date(byAdding: .month, value: months, to: baseDate) if months >= 72 { return "Long journey, strong discipline. You're building momentum." } if let estimatedDate = estimatedDate { return "Estimated: \(estimatedDate.monthYearString)" } return "Estimated: \(months) months" } private func estimateMonthsToGoal(target: Decimal, lastValue: Decimal) -> Int? { guard evolutionData.count >= 3 else { return nil } let recent = Array(evolutionData.suffix(6)) guard let first = recent.first, let last = recent.last else { return nil } let monthsBetween = max(1, first.date.monthsBetween(last.date)) let delta = last.value - first.value guard delta > 0 else { return nil } let monthlyGain = delta / Decimal(monthsBetween) guard monthlyGain > 0 else { return nil } let remaining = target - lastValue guard remaining > 0 else { return 0 } let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue return Int(ceil(months)) } // MARK: - Computed Properties var hasData: Bool { !filteredSources().isEmpty } var totalSourceCount: Int { sourceRepository.sourceCount } var totalCategoryCount: Int { categoryRepository.categories.count } var pendingUpdatesCount: Int { sourcesNeedingUpdate.count } var topCategories: [CategoryMetrics] { Array(categoryMetrics.prefix(5)) } // MARK: - Formatting var formattedTotalValue: String { portfolioSummary.formattedTotalValue } var formattedDayChange: String { portfolioSummary.formattedDayChange } var formattedMonthChange: String { portfolioSummary.formattedMonthChange } var formattedYearChange: String { portfolioSummary.formattedYearChange } var isDayChangePositive: Bool { portfolioSummary.dayChange >= 0 } var isMonthChangePositive: Bool { portfolioSummary.monthChange >= 0 } var isYearChangePositive: Bool { portfolioSummary.yearChange >= 0 } var formattedLastUpdate: String { portfolioSummary.lastUpdated?.friendlyDescription ?? "Not yet" } }