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) } }