Sync monthly contributions y allocation targets vía iCloud (CoreData)

Ambos vivían en UserDefaults (device-local, no sincroniza). Migrados a atributos
CoreData en entidades ya syncable=YES, así que ahora sincronizan por CloudKit:

- InvestmentSource.monthlyContribution (Decimal, opcional)
- Category.allocationTarget (Double, opcional)

Los stores mantienen su API por UUID (0 cambios en call sites) pero ahora leen/escriben
del atributo CoreData vía fetch por id. migrateIfNeeded(context:) mueve una sola vez los
valores del diccionario UserDefaults legacy al modelo y borra la clave; enganchado en
CoreDataStack.loadPersistentStores junto a MonthlyCheckInStore.

DashboardLayoutStore se mantiene en UserDefaults a propósito (preferencia de UI por
dispositivo: el columnSpan solo aplica en iPad).

NOTA: requiere desplegar el schema CloudKit a producción (CD_monthlyContribution,
CD_allocationTarget) antes de que sincronice en TestFlight/App Store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
alexandrev-tibco
2026-07-01 09:28:37 +02:00
parent 51223f7079
commit 9927d21b86
6 changed files with 91 additions and 34 deletions
@@ -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)
}
}
@@ -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)
}
}