initial version
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user