292 lines
10 KiB
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
|
|
}
|