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 // MARK: - Chart Data @Published var evolutionData: [(date: Date, value: Decimal)] = [] // MARK: - Dependencies private let categoryRepository: CategoryRepository private let sourceRepository: InvestmentSourceRepository private let snapshotRepository: SnapshotRepository private let calculationService: CalculationService private var cancellables = Set() private var isRefreshing = false private var refreshQueued = false 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 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 Task { [weak self] in guard let self else { return } while self.refreshQueued { self.refreshQueued = false await self.refreshAllData() } self.isRefreshing = false if updateLoadingFlag { self.isLoading = false } } } 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 updateEvolutionData(from: allSnapshots, categories: categories) latestPortfolioChange = calculateLatestChange(from: evolutionData) // Log screen view FirebaseService.shared.logScreenView(screenName: "Dashboard") } private func filteredSources() -> [InvestmentSource] { // Performance: Cache filtered sources to avoid repeated filtering let currentHash = sourceRepository.sources.count let accountChanged = lastAccountId != selectedAccount?.id || lastShowAllAccounts != showAllAccounts if !accountChanged && cachedFilteredSources != nil && cachedSourcesHash == currentHash { return cachedFilteredSources! } lastAccountId = selectedAccount?.id lastShowAllAccounts = showAllAccounts cachedSourcesHash = currentHash if showAllAccounts || selectedAccount == nil { cachedFilteredSources = sourceRepository.sources } else { cachedFilteredSources = sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id } } 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: [:]) } // Performance: Pre-allocate capacity and use more efficient data structures let sortedSnapshots = snapshots.sorted { $0.date < $1.date } // Use dictionary to deduplicate dates more efficiently var uniqueDateSet = Set() uniqueDateSet.reserveCapacity(sortedSnapshots.count) for snapshot in sortedSnapshots { uniqueDateSet.insert(Calendar.current.startOfDay(for: snapshot.date)) } let uniqueDates = uniqueDateSet.sorted() guard !uniqueDates.isEmpty else { return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:]) } // Pre-allocate dictionaries with estimated capacity var snapshotsBySource: [UUID: [(date: Date, value: Decimal, categoryId: UUID?)]] = [:] snapshotsBySource.reserveCapacity(Set(sortedSnapshots.compactMap { $0.source?.id }).count) var categoryTotals: [UUID: Decimal] = [:] for snapshot in sortedSnapshots { guard let sourceId = snapshot.source?.id else { continue } let categoryId = snapshot.source?.category?.id snapshotsBySource[sourceId, default: []].append( (date: snapshot.date, value: snapshot.decimalValue, categoryId: categoryId) ) if let categoryId { categoryTotals[categoryId, default: 0] += snapshot.decimalValue } } // Pre-allocate result arrays var indices: [UUID: Int] = [:] indices.reserveCapacity(snapshotsBySource.count) var evolution: [(date: Date, value: Decimal)] = [] evolution.reserveCapacity(uniqueDates.count) var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = [] series.reserveCapacity(uniqueDates.count) // Track last known value per source for carry-forward optimization var lastValues: [UUID: (value: Decimal, categoryId: UUID?)] = [:] lastValues.reserveCapacity(snapshotsBySource.count) for (index, date) in uniqueDates.enumerated() { let nextDate = index + 1 < uniqueDates.count ? uniqueDates[index + 1] : Date.distantFuture var total: Decimal = 0 var valuesByCategory: [UUID: Decimal] = [:] for (sourceId, sourceSnapshots) in snapshotsBySource { var currentIndex = indices[sourceId] ?? 0 while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate { let snap = sourceSnapshots[currentIndex] lastValues[sourceId] = (value: snap.value, categoryId: snap.categoryId) currentIndex += 1 } indices[sourceId] = currentIndex // Use last known value (carry-forward) if let lastValue = lastValues[sourceId] { total += lastValue.value if let categoryId = lastValue.categoryId { valuesByCategory[categoryId, default: 0] += lastValue.value } } } evolution.append((date: date, value: total)) series.append((date: date, valuesByCategory: valuesByCategory)) } return EvolutionSummary( evolutionData: evolution, categorySeries: series, categoryTotals: categoryTotals ) } private func calculateLatestChange(from data: [(date: Date, value: Decimal)]) -> PortfolioChange { guard data.count >= 2 else { return PortfolioChange(absolute: 0, percentage: 0, label: "since last update") } 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 update") } func goalEtaText(for goal: Goal, currentValue: Decimal) -> 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" } }