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:
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user