InvestmentTrackerApp/PortfolioJournal/ViewModels/GoalsViewModel.swift

292 lines
10 KiB
Swift

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<AnyCancellable>()
// 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
}