import Foundation import Combine import CoreData @MainActor class GoalsViewModel: ObservableObject { @Published var goals: [Goal] = [] @Published var totalValue: Decimal = Decimal.zero @Published var selectedAccount: Account? @Published var showAllAccounts = true private let goalRepository: GoalRepository private let sourceRepository: InvestmentSourceRepository private let snapshotRepository: SnapshotRepository private let maxHistoryMonths = 60 private var cancellables = Set() // MARK: - Performance: Caching private var cachedEvolutionData: [UUID: [(date: Date, value: Decimal)]] = [:] private var cachedCompletionDates: [UUID: Date?] = [:] private var lastSourcesHash: Int = 0 init( goalRepository: GoalRepository = GoalRepository(), sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(), snapshotRepository: SnapshotRepository = SnapshotRepository() ) { self.goalRepository = goalRepository self.sourceRepository = sourceRepository self.snapshotRepository = snapshotRepository setupObservers() refresh() } private func setupObservers() { goalRepository.$goals .receive(on: DispatchQueue.main) .sink { [weak self] goals in self?.updateGoals(using: goals) } .store(in: &cancellables) sourceRepository.$sources .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateTotalValue() } .store(in: &cancellables) } func refresh() { // Performance: Invalidate caches when refreshing let currentHash = sourceRepository.sources.count if currentHash != lastSourcesHash { cachedEvolutionData.removeAll() cachedCompletionDates.removeAll() lastSourcesHash = currentHash } loadGoals() updateGoals(using: goalRepository.goals) } func progress(for goal: Goal) -> Double { let currentTotal = totalValue(for: goal) guard goal.targetDecimal > 0 else { return 0 } let current = min(currentTotal, goal.targetDecimal) return NSDecimalNumber(decimal: current / goal.targetDecimal).doubleValue } func totalValue(for goal: Goal) -> Decimal { if let account = goal.account { return sourceRepository.sources .filter { $0.account?.id == account.id } .reduce(Decimal.zero) { $0 + $1.latestValue } } return totalValue } func paceStatus(for goal: Goal) -> GoalPaceStatus? { guard let targetDate = goal.targetDate else { return nil } let targetDay = targetDate.startOfDay let actualProgress = progress(for: goal) let startDate = goal.createdAt.startOfDay let totalDays = max(1, startDate.daysBetween(targetDay)) if actualProgress >= 1 { return GoalPaceStatus( expectedProgress: 1, delta: 0, isBehind: false, statusText: "Goal reached" ) } if let estimatedCompletionDate = estimateCompletionDate(for: goal) { let estimatedDay = estimatedCompletionDate.startOfDay let deltaDays = targetDay.daysBetween(estimatedDay) let deltaPercent = min(abs(Double(deltaDays)) / Double(totalDays) * 100, 999) let isBehind = estimatedDay > targetDay let statusText = abs(deltaPercent) < 1 ? "On track" : isBehind ? String(format: "Behind by %.1f%%", deltaPercent) : String(format: "Ahead by %.1f%%", deltaPercent) return GoalPaceStatus( expectedProgress: actualProgress, delta: isBehind ? -deltaPercent / 100 : deltaPercent / 100, isBehind: isBehind, statusText: statusText ) } let elapsedDays = max(0, startDate.daysBetween(Date())) let expectedProgress = min(Double(elapsedDays) / Double(totalDays), 1) let delta = actualProgress - expectedProgress let isOverdue = Date() > targetDay && actualProgress < 1 let isBehind = isOverdue || delta < -0.03 let deltaPercent = abs(delta) * 100 let statusText = isOverdue ? "Behind schedule • target passed" : delta >= 0 ? String(format: "Ahead by %.1f%%", deltaPercent) : String(format: "Behind by %.1f%%", deltaPercent) return GoalPaceStatus( expectedProgress: expectedProgress, delta: delta, isBehind: isBehind, statusText: statusText ) } func deleteGoal(_ goal: Goal) { goalRepository.deleteGoal(goal) } private func estimateCompletionDate(for goal: Goal) -> Date? { // Performance: Use cached completion date if available if let cached = cachedCompletionDates[goal.id] { return cached } let sources: [InvestmentSource] if let account = goal.account { sources = sourceRepository.sources.filter { $0.account?.id == account.id } } else { sources = sourceRepository.sources } let sourceIds = sources.compactMap { $0.id } guard !sourceIds.isEmpty else { cachedCompletionDates[goal.id] = nil return nil } // Performance: Use cached evolution data if available let cacheKey = goal.account?.id ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")! let evolutionData: [(date: Date, value: Decimal)] if let cached = cachedEvolutionData[cacheKey] { evolutionData = cached } else { let snapshots = snapshotRepository.fetchSnapshots( for: sourceIds, months: maxHistoryMonths ) evolutionData = calculateEvolutionData(from: snapshots) cachedEvolutionData[cacheKey] = evolutionData } guard evolutionData.count >= 3, let first = evolutionData.suffix(6).first, let last = evolutionData.suffix(6).last else { cachedCompletionDates[goal.id] = nil return nil } let monthsBetween = max(1, first.date.monthsBetween(last.date)) let delta = last.value - first.value guard delta > 0 else { cachedCompletionDates[goal.id] = nil return nil } let monthlyGain = delta / Decimal(monthsBetween) guard monthlyGain > 0 else { cachedCompletionDates[goal.id] = nil return nil } let currentValue = totalValue(for: goal) let remaining = goal.targetDecimal - currentValue guard remaining > 0 else { let result = Date() cachedCompletionDates[goal.id] = result return result } let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue let monthsRounded = Int(ceil(months)) let result = Calendar.current.date(byAdding: .month, value: monthsRounded, to: last.date) cachedCompletionDates[goal.id] = result return result } private func calculateEvolutionData(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] { let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) })) .sorted() guard !uniqueDates.isEmpty else { return [] } var snapshotsBySource: [UUID: [(date: Date, value: Decimal)]] = [:] for snapshot in sortedSnapshots { guard let sourceId = snapshot.source?.id else { continue } snapshotsBySource[sourceId, default: []].append( (date: snapshot.date, value: snapshot.decimalValue) ) } var indices: [UUID: Int] = [:] var evolution: [(date: Date, value: Decimal)] = [] for (index, date) in uniqueDates.enumerated() { let nextDate = index + 1 < uniqueDates.count ? uniqueDates[index + 1] : Date.distantFuture var total: Decimal = 0 for (sourceId, sourceSnapshots) in snapshotsBySource { var currentIndex = indices[sourceId] ?? 0 var latest: (date: Date, value: Decimal)? while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate { latest = sourceSnapshots[currentIndex] currentIndex += 1 } indices[sourceId] = currentIndex if let latest { total += latest.value } } evolution.append((date: date, value: total)) } return evolution } // MARK: - Private helpers private func loadGoals() { if showAllAccounts || selectedAccount == nil { goalRepository.fetchGoals() } else if let account = selectedAccount { goalRepository.fetchGoals(for: account) } } private func updateGoals(using repositoryGoals: [Goal]) { if showAllAccounts || selectedAccount == nil { goals = repositoryGoals } else if let account = selectedAccount { goals = repositoryGoals.filter { $0.account?.id == account.id } } else { goals = repositoryGoals } updateTotalValue() } private func updateTotalValue() { if showAllAccounts || selectedAccount == nil { totalValue = sourceRepository.sources.reduce(Decimal.zero) { $0 + $1.latestValue } return } if let account = selectedAccount { totalValue = sourceRepository.sources .filter { $0.account?.id == account.id } .reduce(Decimal.zero) { $0 + $1.latestValue } } } } struct GoalPaceStatus { let expectedProgress: Double let delta: Double let isBehind: Bool let statusText: String }