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: [String: [(date: Date, value: Decimal)]] = [:] private var cachedCompletionDates: [UUID: Date?] = [:] private var lastSourcesHash: Int = 0 init( goalRepository: GoalRepository? = nil, sourceRepository: InvestmentSourceRepository? = nil, snapshotRepository: SnapshotRepository? = nil ) { self.goalRepository = goalRepository ?? GoalRepository() self.sourceRepository = sourceRepository ?? InvestmentSourceRepository() self.snapshotRepository = 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 isAchieved(_ goal: Goal) -> Bool { Self.isAchieved(progress: progress(for: goal)) } static func isAchieved(progress: Double) -> Bool { progress >= 0.999 } static func urgencyLevel( targetDate: Date?, isBehind: Bool, isAchieved: Bool, referenceDate: Date = Date() ) -> GoalUrgencyLevel { guard let targetDate else { return .normal } guard !isAchieved else { return .normal } guard isBehind else { return .normal } let daysUntilTarget = referenceDate.startOfDay.daysBetween(targetDate.startOfDay) if daysUntilTarget < 0 { return .critical } return .warning } func totalValue(for goal: Goal) -> Decimal { guard goal.account != nil || goal.category != nil else { return totalValue } return relevantSources(for: goal) .reduce(Decimal.zero) { $0 + $1.latestValue } } 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) } func archiveGoal(_ goal: Goal) { goalRepository.updateGoal(goal, isActive: !goal.isActive) } func estimateCompletionDate(for goal: Goal) -> Date? { projection(for: goal).projectedDate } /// Full forward projection for a goal, blending recent growth trend with the /// monthly contributions of the relevant sources. Cached per goal. func projection(for goal: Goal) -> GoalProjection.Result { let sources = relevantSources(for: goal) let currentValue = totalValue(for: goal) guard !sources.isEmpty else { let cached = GoalProjection.Result( projectedDate: nil, monthsToReach: nil, status: goal.targetDate == nil ? .noTargetDate : .unreachable, monthlyGrowthRate: 0, sparkline: [] ) cachedCompletionDates[goal.id] = nil return cached } let evolutionData = evolutionData(for: goal, sources: sources) let rate = GoalProjection.monthlyGrowthRate(from: evolutionData) let contribution = sources.reduce(Decimal.zero) { $0 + ($1.monthlyContribution?.decimalValue ?? .zero) } let result = GoalProjection.project( currentValue: currentValue, targetAmount: goal.targetDecimal, monthlyGrowthRate: rate, monthlyContribution: contribution, targetDate: goal.targetDate ) cachedCompletionDates[goal.id] = result.projectedDate return result } private func relevantSources(for goal: Goal) -> [InvestmentSource] { goal.relevantSources(from: sourceRepository.sources) } private func evolutionData( for goal: Goal, sources: [InvestmentSource] ) -> [(date: Date, value: Decimal)] { let cacheKey = goal.scopeKey if let cached = cachedEvolutionData[cacheKey] { return cached } let sourceIds = sources.compactMap { $0.id } let snapshots = snapshotRepository.fetchSnapshots(for: sourceIds, months: maxHistoryMonths) let data = calculateEvolutionData(from: snapshots) cachedEvolutionData[cacheKey] = data return data } 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() { let selectedAccountId = selectedAccount?.safeId if showAllAccounts || selectedAccountId == nil { goalRepository.fetchGoals() } else if let account = selectedAccount { goalRepository.fetchGoals(for: account) } } private func updateGoals(using repositoryGoals: [Goal]) { let selectedAccountId = selectedAccount?.safeId if showAllAccounts || selectedAccountId == nil { goals = repositoryGoals } else { goals = repositoryGoals.filter { $0.account?.id == selectedAccountId } } updateTotalValue() } private func updateTotalValue() { let selectedAccountId = selectedAccount?.safeId if showAllAccounts || selectedAccountId == nil { totalValue = sourceRepository.sources.reduce(Decimal.zero) { $0 + $1.latestValue } return } totalValue = sourceRepository.sources .filter { $0.account?.id == selectedAccountId } .reduce(Decimal.zero) { $0 + $1.latestValue } } } struct GoalPaceStatus { let expectedProgress: Double let delta: Double let isBehind: Bool let statusText: String } enum GoalUrgencyLevel: Equatable { case normal case warning case critical }