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
@@ -53,25 +53,21 @@ class GoalRepository: ObservableObject {
// MARK: - Create
/// Check if a goal with the given name already exists in the account (case-insensitive)
func goalExists(name: String, in account: Account?) -> Bool {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.lowercased()
return goals.contains { goal in
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
let accountMatches = goal.account?.id == account?.id
return nameMatches && accountMatches
}
/// Check if a goal with the given name already exists in the same scope
/// (account + category), case-insensitive
func goalExists(name: String, in account: Account?, category: Category? = nil) -> Bool {
findGoal(byName: name, in: account, category: category) != nil
}
/// Find an existing goal by name (case-insensitive) within the same account
func findGoal(byName name: String, in account: Account?) -> Goal? {
/// Find an existing goal by name (case-insensitive) within the same scope
func findGoal(byName name: String, in account: Account?, category: Category? = nil) -> Goal? {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.lowercased()
return goals.first { goal in
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
let accountMatches = goal.account?.id == account?.id
return nameMatches && accountMatches
let categoryMatches = goal.category?.id == category?.id
return nameMatches && accountMatches && categoryMatches
}
}
@@ -80,10 +76,11 @@ class GoalRepository: ObservableObject {
name: String,
targetAmount: Decimal,
targetDate: Date? = nil,
account: Account?
account: Account?,
category: Category? = nil
) -> Goal {
// Check if goal with same name already exists in this account
if let existing = findGoal(byName: name, in: account) {
// Check if goal with same name already exists in this scope
if let existing = findGoal(byName: name, in: account, category: category) {
return existing
}
@@ -92,6 +89,7 @@ class GoalRepository: ObservableObject {
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
goal.targetDate = targetDate
goal.account = account
goal.category = category
save()
return goal
}
@@ -104,7 +102,9 @@ class GoalRepository: ObservableObject {
targetAmount: Decimal? = nil,
targetDate: Date? = nil,
clearTargetDate: Bool = false,
isActive: Bool? = nil
isActive: Bool? = nil,
category: Category? = nil,
clearCategory: Bool = false
) {
if let name = name {
goal.name = name
@@ -120,6 +120,11 @@ class GoalRepository: ObservableObject {
if let isActive = isActive {
goal.isActive = isActive
}
if clearCategory {
goal.category = nil
} else if let category = category {
goal.category = category
}
save()
}