diff --git a/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift
index d74a350..9f18803 100644
--- a/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift
+++ b/PortfolioJournal/Models/CoreData/Category+CoreDataClass.swift
@@ -14,6 +14,7 @@ public class Category: NSManagedObject, Identifiable {
@NSManaged public var icon: String
@NSManaged public var sortOrder: Int16
@NSManaged public var createdAt: Date
+ @NSManaged public var allocationTarget: NSNumber?
@NSManaged public var sources: NSSet?
public override func awakeFromInsert() {
diff --git a/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift
index 41a602f..303dff2 100644
--- a/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift
+++ b/PortfolioJournal/Models/CoreData/InvestmentSource+CoreDataClass.swift
@@ -12,6 +12,7 @@ public class InvestmentSource: NSManagedObject, Identifiable {
@NSManaged public var notificationFrequency: String
@NSManaged public var customFrequencyMonths: Int16
@NSManaged public var isActive: Bool
+ @NSManaged public var monthlyContribution: NSDecimalNumber?
@NSManaged public var createdAt: Date
@NSManaged public var category: Category?
@NSManaged public var account: Account?
diff --git a/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents b/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents
index fd1b3c6..1487447 100644
--- a/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents
+++ b/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents
@@ -25,6 +25,7 @@
+
@@ -38,6 +39,7 @@
+
diff --git a/PortfolioJournal/Models/CoreDataStack.swift b/PortfolioJournal/Models/CoreDataStack.swift
index ddc746a..aa8aca6 100644
--- a/PortfolioJournal/Models/CoreDataStack.swift
+++ b/PortfolioJournal/Models/CoreDataStack.swift
@@ -187,6 +187,9 @@ class CoreDataStack: ObservableObject {
DispatchQueue.main.async {
self?.isLoaded = true
MonthlyCheckInStore.migrateIfNeeded()
+ // Migrate device-local UserDefaults data into Core Data so it syncs via iCloud.
+ MonthlyContributionStore.migrateIfNeeded(context: container.viewContext)
+ AllocationTargetStore.migrateIfNeeded(context: container.viewContext)
}
}
diff --git a/PortfolioJournal/Utilities/AllocationTargetStore.swift b/PortfolioJournal/Utilities/AllocationTargetStore.swift
index fb0443e..51844df 100644
--- a/PortfolioJournal/Utilities/AllocationTargetStore.swift
+++ b/PortfolioJournal/Utilities/AllocationTargetStore.swift
@@ -1,41 +1,65 @@
import Foundation
+import CoreData
+/// Per-category allocation target (percentage).
+///
+/// Backed by the `Category.allocationTarget` Core Data attribute so targets sync across
+/// devices via iCloud/CloudKit. Previously stored in `UserDefaults`, which is device-local
+/// and did NOT sync — `migrateIfNeeded(context:)` moves any legacy values over.
enum AllocationTargetStore {
- private static let targetsKey = "allocationTargets"
+ /// Legacy UserDefaults key (pre-iCloud). Only read once during migration.
+ private static let legacyKey = "allocationTargets"
+
+ private static var viewContext: NSManagedObjectContext { CoreDataStack.shared.viewContext }
static func target(for categoryId: UUID) -> Double? {
- loadTargets()[categoryId.uuidString]
+ guard let category = fetchCategory(categoryId),
+ let target = category.allocationTarget?.doubleValue,
+ target > 0 else { return nil }
+ return target
}
static func setTarget(_ value: Double?, for categoryId: UUID) {
- var targets = loadTargets()
- let key = categoryId.uuidString
+ guard let category = fetchCategory(categoryId) else { return }
if let value, value > 0 {
- targets[key] = value
+ category.allocationTarget = NSNumber(value: value)
} else {
- targets.removeValue(forKey: key)
+ category.allocationTarget = nil
}
- saveTargets(targets)
+ try? viewContext.save()
}
static func totalTargetPercentage(for categoryIds: [UUID]) -> Double {
- let targets = loadTargets()
- return categoryIds.reduce(0) { total, id in
- total + (targets[id.uuidString] ?? 0)
+ categoryIds.reduce(0) { total, id in
+ total + (target(for: id) ?? 0)
}
}
- private static func loadTargets() -> [String: Double] {
- guard let data = UserDefaults.standard.data(forKey: targetsKey),
- let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
- return [:]
- }
- return decoded
+ private static func fetchCategory(_ id: UUID) -> Category? {
+ let request = Category.fetchRequest()
+ request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
+ request.fetchLimit = 1
+ return try? viewContext.fetch(request).first
}
- private static func saveTargets(_ targets: [String: Double]) {
- if let data = try? JSONEncoder().encode(targets) {
- UserDefaults.standard.set(data, forKey: targetsKey)
+ // MARK: - Migration
+
+ /// One-time migration of legacy UserDefaults targets into Core Data so they sync via
+ /// iCloud. Runs on every launch but is a no-op once the legacy key is cleared.
+ static func migrateIfNeeded(context: NSManagedObjectContext) {
+ guard let data = UserDefaults.standard.data(forKey: legacyKey),
+ let dict = try? JSONDecoder().decode([String: Double].self, from: data),
+ !dict.isEmpty else { return }
+
+ let categories = (try? context.fetch(Category.fetchRequest())) ?? []
+ var changed = false
+ for category in categories where category.allocationTarget == nil {
+ if let value = dict[category.id.uuidString], value > 0 {
+ category.allocationTarget = NSNumber(value: value)
+ changed = true
+ }
}
+ if changed { try? context.save() }
+ UserDefaults.standard.removeObject(forKey: legacyKey)
}
}
diff --git a/PortfolioJournal/Utilities/MonthlyContributionStore.swift b/PortfolioJournal/Utilities/MonthlyContributionStore.swift
index 38d24c1..89ee4b0 100644
--- a/PortfolioJournal/Utilities/MonthlyContributionStore.swift
+++ b/PortfolioJournal/Utilities/MonthlyContributionStore.swift
@@ -1,33 +1,59 @@
import Foundation
+import CoreData
+/// Per-source recurring monthly contribution amount.
+///
+/// Backed by the `InvestmentSource.monthlyContribution` Core Data attribute so the value
+/// syncs across devices via iCloud/CloudKit. Previously stored in `UserDefaults`, which is
+/// device-local and did NOT sync — `migrateIfNeeded(context:)` moves any legacy value over.
enum MonthlyContributionStore {
- private static let key = "monthlyContributions"
+ /// Legacy UserDefaults key (pre-iCloud). Only read once during migration.
+ private static let legacyKey = "monthlyContributions"
+
+ private static var viewContext: NSManagedObjectContext { CoreDataStack.shared.viewContext }
static func contribution(for sourceId: UUID) -> Decimal? {
- let dict = load()
- guard let raw = dict[sourceId.uuidString] else { return nil }
- return Decimal(raw)
+ guard let source = fetchSource(sourceId),
+ let amount = source.monthlyContribution?.decimalValue,
+ amount > 0 else { return nil }
+ return amount
}
static func setContribution(_ amount: Decimal?, for sourceId: UUID) {
- var dict = load()
+ guard let source = fetchSource(sourceId) else { return }
if let amount, amount > 0 {
- dict[sourceId.uuidString] = NSDecimalNumber(decimal: amount).doubleValue
+ source.monthlyContribution = NSDecimalNumber(decimal: amount)
} else {
- dict.removeValue(forKey: sourceId.uuidString)
+ source.monthlyContribution = nil
}
- save(dict)
+ try? viewContext.save()
}
- private static func load() -> [String: Double] {
- guard let data = UserDefaults.standard.data(forKey: key),
- let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else { return [:] }
- return decoded
+ private static func fetchSource(_ id: UUID) -> InvestmentSource? {
+ let request = InvestmentSource.fetchRequest()
+ request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
+ request.fetchLimit = 1
+ return try? viewContext.fetch(request).first
}
- private static func save(_ dict: [String: Double]) {
- if let data = try? JSONEncoder().encode(dict) {
- UserDefaults.standard.set(data, forKey: key)
+ // MARK: - Migration
+
+ /// One-time migration of legacy UserDefaults contributions into Core Data so they sync
+ /// via iCloud. Runs on every launch but is a no-op once the legacy key is cleared.
+ static func migrateIfNeeded(context: NSManagedObjectContext) {
+ guard let data = UserDefaults.standard.data(forKey: legacyKey),
+ let dict = try? JSONDecoder().decode([String: Double].self, from: data),
+ !dict.isEmpty else { return }
+
+ let sources = (try? context.fetch(InvestmentSource.fetchRequest())) ?? []
+ var changed = false
+ for source in sources where source.monthlyContribution == nil {
+ if let raw = dict[source.id.uuidString], raw > 0 {
+ source.monthlyContribution = NSDecimalNumber(value: raw)
+ changed = true
+ }
}
+ if changed { try? context.save() }
+ UserDefaults.standard.removeObject(forKey: legacyKey)
}
}