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
This commit is contained in:
alexandrev-tibco
2026-09-12 09:32:02 +02:00
parent eaf0c8f93b
commit d88cc59627
21 changed files with 256 additions and 77 deletions
@@ -635,6 +635,29 @@ class DashboardViewModel: ObservableObject {
let value: Decimal
}
/// ETA text for a goal scoped to an account and/or a category. The dashboard
/// evolution covers the whole (selected) portfolio, so a scoped goal e.g.
/// "100k in Cash" must be projected over its own sources instead; the
/// caller passes that projection in.
func goalEtaText(for goal: Goal, currentValue: Decimal, projection: GoalProjection.Result) -> String? {
if currentValue >= goal.targetDecimal {
return "Goal reached. Keep it steady."
}
guard let months = projection.monthsToReach else {
return "Keep going. Consistency pays off."
}
if months == 0 {
return "Almost there. One more check-in."
}
if months >= 72 {
return "Long journey, strong discipline. You're building momentum."
}
if let projectedDate = projection.projectedDate {
return "Estimated: \(projectedDate.monthYearString)"
}
return "Estimated: \(months) months"
}
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
let target = goal.targetDecimal
let lastValue = evolutionData.last?.value ?? currentValue