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
@@ -28,6 +28,7 @@ public class Category: NSManagedObject, Identifiable {
@NSManaged public var createdAt: Date
@NSManaged public var allocationTarget: NSNumber?
@NSManaged public var sources: NSSet?
@NSManaged public var goals: NSSet?
public override func awakeFromInsert() {
super.awakeFromInsert()
@@ -43,6 +44,13 @@ public class Category: NSManagedObject, Identifiable {
// MARK: - Computed Properties
extension Category {
/// Nil-safe identifier that never heals a deleted object, mirroring
/// `Account.safeId`. Use it when comparing identities across fetches.
var safeId: UUID? {
guard !isDeleted else { return nil }
return value(forKey: "id") as? UUID
}
var color: Color {
Color(hex: colorHex) ?? .blue
}
@@ -26,6 +26,7 @@ public class Goal: NSManagedObject, Identifiable {
set { setManagedValue(newValue, forKey: "createdAt") }
}
@NSManaged public var account: Account?
@NSManaged public var category: Category?
public override func awakeFromInsert() {
super.awakeFromInsert()
@@ -42,5 +43,32 @@ extension Goal {
var targetDecimal: Decimal {
targetAmount?.decimalValue ?? Decimal.zero
}
/// A goal may be scoped to an account, to a source category (e.g. "100k in
/// Cash"), to both, or to nothing at all (the whole portfolio). `account`
/// and `category` act as AND filters over the sources that count towards it.
func includes(_ source: InvestmentSource) -> Bool {
if let accountId = account?.safeId, source.account?.id != accountId {
return false
}
if let categoryId = category?.safeId, source.category?.id != categoryId {
return false
}
return true
}
/// Sources whose value counts towards this goal.
func relevantSources(from sources: [InvestmentSource]) -> [InvestmentSource] {
guard account != nil || category != nil else { return sources }
return sources.filter { includes($0) }
}
/// Stable key identifying the scope (account + category) of a goal, so
/// goals sharing a scope can share cached evolution data.
var scopeKey: String {
let accountPart = account?.safeId?.uuidString ?? "all"
let categoryPart = category?.safeId?.uuidString ?? "all"
return "\(accountPart)|\(categoryPart)"
}
}
@@ -32,6 +32,7 @@
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="sortOrder" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Goal" inverseName="category" inverseEntity="Goal"/>
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="category" inverseEntity="InvestmentSource"/>
</entity>
<entity name="InvestmentSource" representedClassName="InvestmentSource" syncable="YES">
@@ -76,6 +77,7 @@
<attribute name="targetAmount" optional="YES" attributeType="Decimal"/>
<attribute name="targetDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="goals" inverseEntity="Account"/>
<relationship name="category" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Category" inverseName="goals" inverseEntity="Category"/>
</entity>
<entity name="PredictionCache" representedClassName="PredictionCache" syncable="NO">
<attribute name="algorithm" attributeType="String" defaultValueString="linear"/>