d88cc59627
Un Goal ya podía acotarse por cuenta; ahora también por categoría de fuente, de modo que se puede fijar "100.000 € en Cash" y que el progreso cuente solo las fuentes de esa categoría. - CoreData: relación Goal.category ↔ Category.goals (opcional, Nullify) - Goal.includes(_:)/relevantSources(from:)/scopeKey centralizan el filtro de ámbito (cuenta Y categoría) que antes estaba duplicado en GoalsViewModel, AddSourceView y NotificationService - GoalEditorView: sección "Ámbito" con selector de categoría - Chip de categoría en la lista de Goals y en la tarjeta del Dashboard - ETA del Dashboard para goals con ámbito: usa la proyección del propio goal en vez de la evolución global de la cartera (que daría "objetivo alcanzado" en cuanto la cartera total superara el importe) - GoalRepository e ImportService deduplican por (nombre, cuenta, categoría) - Export/Import de goals incluye "category" - Nuevas claves localizadas en los 7 idiomas Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YcDn5ccuRFV83q7xWWokBT
312 lines
11 KiB
Swift
312 lines
11 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: [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
|
|
}
|