Files
InvestmentTrackerApp/PortfolioJournal/ViewModels/GoalsViewModel.swift
T
alexandrev-tibco d88cc59627 Goals por categoría: ámbito de categoría en objetivos (build 88) #46
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
2026-09-12 09:32:02 +02:00

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
}