From 270edeb536e0bedf00c35e8b48e97bffb3ac1c27 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Tue, 26 May 2026 15:05:53 +0200 Subject: [PATCH] Migrate journal entries to CoreData for iCloud sync (build 33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add JournalEntry CoreData entity (syncable=YES): id, monthKey, note, moodRaw, rating, completionTime, createdAt - Rewrite MonthlyCheckInStore: CoreData as primary storage, same public API - One-time migration from UserDefaults triggered after store loads - MonthlyCheckInCard: @FetchRequest on JournalEntry — reactive to iCloud sync - MonthlyCheckInView: onReceive NSManagedObjectContextObjectsDidChange to refresh @State vars on sync - Stats cards: observe NSManagedObjectContextObjectsDidChange instead of UserDefaults - ChartsViewModel.completedMonthKeys: remove MonthlyCheckInStore dependency (data-driven) Co-Authored-By: Claude Sonnet 4.6 --- PortfolioJournal.xcodeproj/project.pbxproj | 16 +- .../CoreData/JournalEntry+CoreDataClass.swift | 38 ++ .../PortfolioJournal.xcdatamodel/contents | 9 + PortfolioJournal/Models/CoreDataStack.swift | 1 + .../Utilities/MonthlyCheckInStore.swift | 370 ++++++++---------- .../ViewModels/ChartsViewModel.swift | 28 +- .../Views/Dashboard/DashboardView.swift | 31 +- .../Views/Dashboard/MonthlyCheckInView.swift | 19 +- 8 files changed, 236 insertions(+), 276 deletions(-) create mode 100644 PortfolioJournal/Models/CoreData/JournalEntry+CoreDataClass.swift diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index 6e14595..d73c5fb 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -459,7 +459,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements; ENABLE_USER_SCRIPT_SANDBOXING = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -498,7 +498,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements; ENABLE_USER_SCRIPT_SANDBOXING = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -655,7 +655,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -688,7 +688,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -719,7 +719,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -743,7 +743,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -766,7 +766,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -789,7 +789,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 32; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; diff --git a/PortfolioJournal/Models/CoreData/JournalEntry+CoreDataClass.swift b/PortfolioJournal/Models/CoreData/JournalEntry+CoreDataClass.swift new file mode 100644 index 0000000..b64aa64 --- /dev/null +++ b/PortfolioJournal/Models/CoreData/JournalEntry+CoreDataClass.swift @@ -0,0 +1,38 @@ +import Foundation +import CoreData + +@objc(JournalEntry) +public class JournalEntry: NSManagedObject, Identifiable { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "JournalEntry") + } + + @NSManaged public var id: UUID? + @NSManaged public var monthKey: String? + @NSManaged public var note: String? + @NSManaged public var moodRaw: String? + @NSManaged public var rating: Int16 + @NSManaged public var completionTime: Date? + @NSManaged public var createdAt: Date? + + public override func awakeFromInsert() { + super.awakeFromInsert() + id = UUID() + createdAt = Date() + } + + var mood: MonthlyCheckInMood? { + get { moodRaw.flatMap { MonthlyCheckInMood(rawValue: $0) } } + set { moodRaw = newValue?.rawValue } + } + + var ratingValue: Int? { + get { rating > 0 ? Int(rating) : nil } + set { rating = Int16(newValue.map { min(max(1, $0), 5) } ?? 0) } + } + + var completionDate: Date? { + get { completionTime } + set { completionTime = newValue } + } +} diff --git a/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents b/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents index a309e8d..fd1b3c6 100644 --- a/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents +++ b/PortfolioJournal/Models/CoreData/PortfolioJournal.xcdatamodeld/PortfolioJournal.xcdatamodel/contents @@ -102,4 +102,13 @@ + + + + + + + + + diff --git a/PortfolioJournal/Models/CoreDataStack.swift b/PortfolioJournal/Models/CoreDataStack.swift index 92ba3ee..a7b8bde 100644 --- a/PortfolioJournal/Models/CoreDataStack.swift +++ b/PortfolioJournal/Models/CoreDataStack.swift @@ -186,6 +186,7 @@ class CoreDataStack: ObservableObject { } DispatchQueue.main.async { self?.isLoaded = true + MonthlyCheckInStore.migrateIfNeeded() } } diff --git a/PortfolioJournal/Utilities/MonthlyCheckInStore.swift b/PortfolioJournal/Utilities/MonthlyCheckInStore.swift index a0a34a4..353a6fb 100644 --- a/PortfolioJournal/Utilities/MonthlyCheckInStore.swift +++ b/PortfolioJournal/Utilities/MonthlyCheckInStore.swift @@ -1,16 +1,27 @@ import Foundation +import CoreData +// MonthlyCheckInStore — primary storage is CoreData (syncs via iCloud). +// UserDefaults is kept for one-time migration from older builds. enum MonthlyCheckInStore { + // Legacy UserDefaults keys (read-only after migration) private static let notesKey = "monthlyCheckInNotes" private static let completionsKey = "monthlyCheckInCompletions" private static let legacyLastCheckInKey = "lastCheckInDate" private static let entriesKey = "monthlyCheckInEntries" - private static let graceDays = 20 + private static let migrationDoneKey = "journalMigratedToCoreData" + static let graceDays = 20 + + // MARK: - CoreData Context + + private static var context: NSManagedObjectContext { + CoreDataStack.shared.viewContext + } // MARK: - Public Accessors static func note(for date: Date) -> String { - entry(for: date)?.note ?? "" + fetchEntry(for: monthKey(for: date))?.note ?? "" } static func setNote(_ note: String, for date: Date) { @@ -21,21 +32,17 @@ enum MonthlyCheckInStore { } static func rating(for date: Date) -> Int? { - entry(for: date)?.rating + fetchEntry(for: monthKey(for: date))?.ratingValue } static func setRating(_ rating: Int?, for date: Date) { updateEntry(for: date) { entry in - if let rating, rating > 0 { - entry.rating = min(max(1, rating), 5) - } else { - entry.rating = nil - } + entry.ratingValue = rating } } static func mood(for date: Date) -> MonthlyCheckInMood? { - entry(for: date)?.mood + fetchEntry(for: monthKey(for: date))?.mood } static func setMood(_ mood: MonthlyCheckInMood?, for date: Date) { @@ -45,114 +52,98 @@ enum MonthlyCheckInStore { } static func monthKey(for date: Date) -> String { - Self.monthFormatter.string(from: effectiveMonth(for: date)) + monthFormatter.string(from: effectiveMonth(for: date)) } static func allNotes() -> [(date: Date, note: String)] { - loadEntries() - .compactMap { key, entry in - guard let date = Self.monthFormatter.date(from: key) else { return nil } + fetchAllEntries() + .compactMap { entry in + guard let key = entry.monthKey, + let date = monthFormatter.date(from: key) else { return nil } return (date: date, note: entry.note ?? "") } .sorted { $0.date > $1.date } } static func entry(for date: Date) -> MonthlyCheckInEntry? { - loadEntries()[monthKey(for: date)] + fetchEntry(for: monthKey(for: date)).map(makeCheckInEntry) } static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] { - loadEntries() - .compactMap { key, entry in - guard let date = Self.monthFormatter.date(from: key) else { return nil } - return (date: date, entry: entry) + fetchAllEntries() + .compactMap { entry in + guard let key = entry.monthKey, + let date = monthFormatter.date(from: key) else { return nil } + return (date: date, entry: makeCheckInEntry(entry)) } .sorted { $0.date > $1.date } } static func completionDate(for date: Date) -> Date? { - entry(for: date)?.completionDate + fetchEntry(for: monthKey(for: date))?.completionTime } static func setCompletionDate(_ completionDate: Date, for month: Date) { let targetMonth = effectiveMonth(for: month, relativeTo: completionDate, graceDays: graceDays) - let targetKey = monthKey(for: targetMonth) - var entries = loadEntries() - var didChange = false + let targetKey = monthFormatter.string(from: targetMonth) let calendar = Calendar.current if calendar.isDate(month, inSameDayAs: completionDate), calendar.component(.day, from: completionDate) > graceDays { - let completedEntries = entries.compactMap { key, entry -> (month: Date, entry: MonthlyCheckInEntry)? in - guard let monthDate = monthFormatter.date(from: key)?.startOfMonth else { return nil } - guard entry.completionTime != nil else { return nil } - guard monthDate < targetMonth else { return nil } - return (month: monthDate, entry: entry) + + let allExisting = fetchAllEntries() + let completedPrevious = allExisting.compactMap { entry -> (month: Date, entry: JournalEntry)? in + guard let key = entry.monthKey, + let entryMonth = monthFormatter.date(from: key)?.startOfMonth, + entry.completionTime != nil, + entryMonth < targetMonth else { return nil } + return (month: entryMonth, entry: entry) } - if let lastCompleted = completedEntries.max(by: { $0.month < $1.month }) { + if let lastCompleted = completedPrevious.max(by: { $0.month < $1.month }) { var cursor = lastCompleted.month.adding(months: 1).startOfMonth while cursor < targetMonth { let key = monthFormatter.string(from: cursor) - if entries[key] == nil { - let fallbackCompletion = min(cursor.endOfMonth, completionDate) - let entry = MonthlyCheckInEntry( - note: lastCompleted.entry.note, - rating: lastCompleted.entry.rating, - mood: lastCompleted.entry.mood, - completionTime: fallbackCompletion.timeIntervalSince1970, - createdAt: Date().timeIntervalSince1970 - ) - entries[key] = entry - didChange = true + if fetchEntry(for: key) == nil { + let fallbackDate = min(cursor.endOfMonth, completionDate) + let new = JournalEntry(context: context) + new.monthKey = key + new.note = lastCompleted.entry.note + new.ratingValue = lastCompleted.entry.ratingValue + new.mood = lastCompleted.entry.mood + new.completionTime = fallbackDate } cursor = cursor.adding(months: 1).startOfMonth } } } - // Ensure the target month entry exists. - var targetEntry = entries[targetKey] ?? MonthlyCheckInEntry( - note: nil, - rating: nil, - mood: nil, - completionTime: legacyCompletion(for: targetKey), - createdAt: Date().timeIntervalSince1970 - ) - targetEntry.completionTime = completionDate.timeIntervalSince1970 - entries[targetKey] = targetEntry - didChange = true + // Ensure target month entry exists with the completion date. + let targetEntry = fetchEntry(for: targetKey) ?? { + let e = JournalEntry(context: context) + e.monthKey = targetKey + return e + }() + targetEntry.completionTime = completionDate - // Mark all previous pending entries as completed as well. - for (key, entry) in entries { - guard let entryMonth = monthFormatter.date(from: key)?.startOfMonth else { continue } - guard entryMonth < targetMonth else { continue } - guard entry.completionTime == nil else { continue } - - var updated = entry - let fallbackCompletion = min(entryMonth.endOfMonth, completionDate) - updated.completionTime = fallbackCompletion.timeIntervalSince1970 - entries[key] = updated - didChange = true + // Backfill any previous entries that have no completion date. + for entry in fetchAllEntries() { + guard let key = entry.monthKey, + let entryMonth = monthFormatter.date(from: key)?.startOfMonth, + entryMonth < targetMonth, + entry.completionTime == nil else { continue } + entry.completionTime = min(entryMonth.endOfMonth, completionDate) } - guard didChange else { return } - saveEntries(entries) - persistLegacyMirrors(entries) + saveContext() } static func latestCompletionDate() -> Date? { - let latestEntryDate = loadEntries().values - .compactMap { $0.completionDate } - .max() - - if let latestEntryDate { - return latestEntryDate - } - - let legacy = UserDefaults.standard.double(forKey: legacyLastCheckInKey) - guard legacy > 0 else { return nil } - return Date(timeIntervalSince1970: legacy) + let request = JournalEntry.fetchRequest() + request.predicate = NSPredicate(format: "completionTime != nil") + request.sortDescriptors = [NSSortDescriptor(keyPath: \JournalEntry.completionTime, ascending: false)] + request.fetchLimit = 1 + return (try? context.fetch(request))?.first?.completionTime } static func effectiveMonth( @@ -192,7 +183,6 @@ enum MonthlyCheckInStore { let totalCheckIns = completions.count let onTimeCount = onTimeMonths.count - // Current streak counts consecutive on-time months up to the reference month. var currentStreak = 0 var cursor = referenceDate.startOfMonth while onTimeMonths.contains(cursor) { @@ -200,7 +190,6 @@ enum MonthlyCheckInStore { cursor = cursor.adding(months: -1).startOfMonth } - // Best streak across history. let sortedMonths = onTimeMonths.sorted() var bestStreak = 0 var running = 0 @@ -216,9 +205,7 @@ enum MonthlyCheckInStore { } let averageDaysBeforeDeadline = onTimeCount > 0 - ? deadlineDiffs - .filter { $0 >= 0 } - .average() + ? deadlineDiffs.filter { $0 >= 0 }.average() : nil let closestCutoffDays = onTimeCount > 0 ? deadlineDiffs.filter { $0 >= 0 }.min() @@ -252,6 +239,10 @@ enum MonthlyCheckInStore { } static func clearAll() { + for entry in fetchAllEntries() { + context.delete(entry) + } + saveContext() let defaults = UserDefaults.standard defaults.removeObject(forKey: notesKey) defaults.removeObject(forKey: completionsKey) @@ -259,168 +250,129 @@ enum MonthlyCheckInStore { defaults.removeObject(forKey: legacyLastCheckInKey) } - // MARK: - Private Helpers + // MARK: - One-time Migration from UserDefaults - private static func updateEntry(for date: Date, mutate: (inout MonthlyCheckInEntry) -> Void) { + static func migrateIfNeeded() { + guard !UserDefaults.standard.bool(forKey: migrationDoneKey) else { return } + + let legacyEntries = loadLegacyEntries() + for (key, legacy) in legacyEntries { + guard fetchEntry(for: key) == nil else { continue } + let entry = JournalEntry(context: context) + entry.monthKey = key + entry.note = legacy.note + entry.ratingValue = legacy.rating + entry.mood = legacy.mood + if let t = legacy.completionTime { + entry.completionTime = Date(timeIntervalSince1970: t) + } + entry.createdAt = Date(timeIntervalSince1970: legacy.createdAt) + } + + saveContext() + UserDefaults.standard.set(true, forKey: migrationDoneKey) + } + + // MARK: - Private CoreData Helpers + + private static func fetchEntry(for key: String) -> JournalEntry? { + let request = JournalEntry.fetchRequest() + request.predicate = NSPredicate(format: "monthKey == %@", key) + request.fetchLimit = 1 + return try? context.fetch(request).first + } + + private static func fetchAllEntries() -> [JournalEntry] { + let request = JournalEntry.fetchRequest() + return (try? context.fetch(request)) ?? [] + } + + private static func updateEntry(for date: Date, mutate: (JournalEntry) -> Void) { let key = monthKey(for: date) - var entries = loadEntries() - var entry = entries[key] ?? MonthlyCheckInEntry( - note: nil, - rating: nil, - mood: nil, - completionTime: legacyCompletion(for: key), - createdAt: Date().timeIntervalSince1970 - ) + let entry = fetchEntry(for: key) ?? { + let e = JournalEntry(context: context) + e.monthKey = key + return e + }() - mutate(&entry) + mutate(entry) if entry.note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true { entry.note = nil } - let isEmpty = entry.note == nil && entry.rating == nil && entry.mood == nil && entry.completionTime == nil + let isEmpty = entry.note == nil + && entry.ratingValue == nil + && entry.mood == nil + && entry.completionTime == nil if isEmpty { - entries.removeValue(forKey: key) - } else { - entries[key] = entry + context.delete(entry) } - saveEntries(entries) - persistLegacyMirrors(entries) + saveContext() } - private static func loadEntries() -> [String: MonthlyCheckInEntry] { - guard let data = UserDefaults.standard.data(forKey: entriesKey), - let decoded = try? JSONDecoder().decode([String: MonthlyCheckInEntry].self, from: data) else { - return migrateLegacyData() + private static func saveContext() { + guard context.hasChanges else { return } + try? context.save() + } + + private static func makeCheckInEntry(_ entry: JournalEntry) -> MonthlyCheckInEntry { + MonthlyCheckInEntry( + note: entry.note, + rating: entry.ratingValue, + mood: entry.mood, + completionTime: entry.completionTime?.timeIntervalSince1970, + createdAt: entry.createdAt?.timeIntervalSince1970 ?? Date().timeIntervalSince1970 + ) + } + + // MARK: - Legacy UserDefaults Reader (for migration only) + + private static func loadLegacyEntries() -> [String: MonthlyCheckInEntry] { + if let data = UserDefaults.standard.data(forKey: entriesKey), + let decoded = try? JSONDecoder().decode([String: MonthlyCheckInEntry].self, from: data), + !decoded.isEmpty { + return decoded } - - // Ensure legacy data is merged if it existed before this release. - return mergeLegacy(into: decoded) + return loadAndMergeLegacyKeys() } - private static func saveEntries(_ entries: [String: MonthlyCheckInEntry]) { - guard let data = try? JSONEncoder().encode(entries) else { return } - UserDefaults.standard.set(data, forKey: entriesKey) - } - - private static func migrateLegacyData() -> [String: MonthlyCheckInEntry] { - let notes = loadNotes() - let completions = loadCompletions() + private static func loadAndMergeLegacyKeys() -> [String: MonthlyCheckInEntry] { + let notes = loadLegacyNotes() + let completions = loadLegacyCompletions() guard !notes.isEmpty || !completions.isEmpty else { return [:] } var entries: [String: MonthlyCheckInEntry] = [:] let now = Date().timeIntervalSince1970 - for (key, note) in notes { entries[key] = MonthlyCheckInEntry( - note: note, - rating: nil, - mood: nil, - completionTime: completions[key], - createdAt: now + note: note, rating: nil, mood: nil, + completionTime: completions[key], createdAt: now ) } - for (key, completion) in completions where entries[key] == nil { entries[key] = MonthlyCheckInEntry( - note: nil, - rating: nil, - mood: nil, - completionTime: completion, - createdAt: completion + note: nil, rating: nil, mood: nil, + completionTime: completion, createdAt: completion ) } - - saveEntries(entries) return entries } - private static func mergeLegacy(into entries: [String: MonthlyCheckInEntry]) -> [String: MonthlyCheckInEntry] { - var merged = entries - let notes = loadNotes() - let completions = loadCompletions() - var shouldSave = false - - for (key, note) in notes where merged[key]?.note == nil { - var entry = merged[key] ?? MonthlyCheckInEntry( - note: nil, - rating: nil, - mood: nil, - completionTime: completions[key], - createdAt: Date().timeIntervalSince1970 - ) - entry.note = note - merged[key] = entry - shouldSave = true - } - - for (key, completion) in completions where merged[key]?.completionTime == nil { - var entry = merged[key] ?? MonthlyCheckInEntry( - note: nil, - rating: nil, - mood: nil, - completionTime: nil, - createdAt: completion - ) - entry.completionTime = completion - merged[key] = entry - shouldSave = true - } - - if shouldSave { - saveEntries(merged) - } - - return merged - } - - private static func persistLegacyMirrors(_ entries: [String: MonthlyCheckInEntry]) { - var notes: [String: String] = [:] - var completions: [String: Double] = [:] - - for (key, entry) in entries { - if let note = entry.note { - notes[key] = note - } - if let completion = entry.completionTime { - completions[key] = completion - } - } - - saveNotes(notes) - saveCompletions(completions) - } - - private static func loadNotes() -> [String: String] { + private static func loadLegacyNotes() -> [String: String] { guard let data = UserDefaults.standard.data(forKey: notesKey), - let decoded = try? JSONDecoder().decode([String: String].self, from: data) else { - return [:] - } + let decoded = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] } return decoded } - private static func saveNotes(_ notes: [String: String]) { - guard let data = try? JSONEncoder().encode(notes) else { return } - UserDefaults.standard.set(data, forKey: notesKey) - } - - private static func loadCompletions() -> [String: Double] { + private static func loadLegacyCompletions() -> [String: Double] { guard let data = UserDefaults.standard.data(forKey: completionsKey), - let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else { - return [:] - } + let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else { return [:] } return decoded } - private static func saveCompletions(_ completions: [String: Double]) { - guard let data = try? JSONEncoder().encode(completions) else { return } - UserDefaults.standard.set(data, forKey: completionsKey) - } - - private static func legacyCompletion(for key: String) -> Double? { - loadCompletions()[key] - } + // MARK: - Private Achievement Helpers private struct MonthlyCheckInAchievementRule { let achievement: MonthlyCheckInAchievement @@ -474,9 +426,7 @@ enum MonthlyCheckInStore { icon: "hourglass" ), isUnlocked: { _, _, _, _, closestCutoffDays, _ in - if let closestCutoffDays { - return closestCutoffDays <= 2 - } + if let closestCutoffDays { return closestCutoffDays <= 2 } return false } ), @@ -522,12 +472,8 @@ enum MonthlyCheckInStore { ) -> [MonthlyCheckInAchievement] { achievementRules.compactMap { rule in rule.isUnlocked( - currentStreak, - bestStreak, - onTimeCount, - totalCheckIns, - closestCutoffDays, - averageDaysBeforeDeadline + currentStreak, bestStreak, onTimeCount, totalCheckIns, + closestCutoffDays, averageDaysBeforeDeadline ) ? rule.achievement : nil } } diff --git a/PortfolioJournal/ViewModels/ChartsViewModel.swift b/PortfolioJournal/ViewModels/ChartsViewModel.swift index fbead2f..505bb63 100644 --- a/PortfolioJournal/ViewModels/ChartsViewModel.swift +++ b/PortfolioJournal/ViewModels/ChartsViewModel.swift @@ -709,8 +709,7 @@ class ChartsViewModel: ObservableObject { private func completedMonthKeys( sources: [InvestmentSource], - snapshots: [Snapshot], - after cutoff: Date + snapshots: [Snapshot] ) -> Set { let sourceIds = Set(sources.compactMap { $0.id }) guard !sourceIds.isEmpty else { return [] } @@ -723,9 +722,8 @@ class ChartsViewModel: ObservableObject { completed.reserveCapacity(groupedByMonth.count) for (key, monthSnapshots) in groupedByMonth { - guard let monthDate = Calendar.current.date(from: key) else { continue } - guard monthDate > cutoff else { continue } - guard MonthlyCheckInStore.completionDate(for: monthDate) != nil else { continue } + // A month is complete when all active sources have snapshot data for it. + // MonthlyCheckInStore (UserDefaults) is NOT synced via iCloud — don't use it as a filter. let monthSourceIds = Set(monthSnapshots.compactMap { $0.source?.id }) if sourceIds.isSubset(of: monthSourceIds) { completed.insert(key) @@ -739,24 +737,8 @@ class ChartsViewModel: ObservableObject { sources: [InvestmentSource], snapshots: [Snapshot] ) -> [Snapshot] { - guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else { - return snapshots - } - - let completedMonthsAfter = completedMonthKeys( - sources: sources, - snapshots: snapshots, - after: lastCompleted - ) - - return snapshots.filter { snapshot in - let monthDate = chartMonthStart(for: snapshot.date) - if monthDate <= lastCompleted { - return true - } - let key = chartMonth(for: snapshot.date) - return completedMonthsAfter.contains(key) - } + let completedKeys = completedMonthKeys(sources: sources, snapshots: snapshots) + return snapshots.filter { completedKeys.contains(chartMonth(for: $0.date)) } } private func groupSnapshotsBySource(_ snapshots: [Snapshot]) -> [UUID: [Snapshot]] { diff --git a/PortfolioJournal/Views/Dashboard/DashboardView.swift b/PortfolioJournal/Views/Dashboard/DashboardView.swift index 743ba92..eb48a0a 100644 --- a/PortfolioJournal/Views/Dashboard/DashboardView.swift +++ b/PortfolioJournal/Views/Dashboard/DashboardView.swift @@ -1,5 +1,6 @@ import SwiftUI import Charts +import CoreData struct DashboardView: View { @EnvironmentObject var iapService: IAPService @@ -361,10 +362,7 @@ struct DashboardView: View { if config.isCollapsed { CompactCard(title: "Monthly Check-in", subtitle: "Last update: \(viewModel.formattedLastUpdate)") } else { - MonthlyCheckInCard( - lastUpdated: viewModel.formattedLastUpdate, - lastUpdatedDate: viewModel.portfolioSummary.lastUpdated - ) + MonthlyCheckInCard(lastUpdated: viewModel.formattedLastUpdate) } case .momentumStreaks: if config.isCollapsed { @@ -594,15 +592,16 @@ struct TotalValueCard: View { struct MonthlyCheckInCard: View { let lastUpdated: String - let lastUpdatedDate: Date? @State private var startDestinationActive = false - @State private var cachedCompletionDate: Date? + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \JournalEntry.completionTime, ascending: false)], + predicate: NSPredicate(format: "completionTime != nil"), + animation: .none + ) private var latestJournalEntry: FetchedResults private var effectiveLastCheckInDate: Date? { - // Use whichever is more recent: local completion record or latest snapshot date from CoreData. - // cachedCompletionDate lives in UserDefaults (device-local, not iCloud-synced), so on a - // secondary device after iCloud sync the snapshot date may be newer than the local record. - [cachedCompletionDate, lastUpdatedDate].compactMap { $0 }.max() + latestJournalEntry.first?.completionTime } private var checkInProgress: Double { @@ -762,14 +761,6 @@ struct MonthlyCheckInCard: View { .background(Color(.systemBackground)) .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) - .onAppear { - cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate() - } - .onChange(of: startDestinationActive) { _, isActive in - if !isActive { - cachedCompletionDate = MonthlyCheckInStore.latestCompletionDate() - } - } } private var defaultReminderTime: Date { @@ -895,7 +886,7 @@ struct MomentumStreaksCard: View { .cornerRadius(AppConstants.UI.cornerRadius) .shadow(color: .black.opacity(0.05), radius: 8, y: 2) .onAppear(perform: refreshStats) - .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in refreshStats() } } @@ -968,7 +959,7 @@ struct MomentumStreaksCompactCard: View { subtitle: "Streak: \(stats.currentStreak)x • Best: \(stats.bestStreak)x" ) .onAppear(perform: refreshStats) - .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in refreshStats() } } diff --git a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift index 95a24a1..906f1eb 100644 --- a/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift +++ b/PortfolioJournal/Views/Dashboard/MonthlyCheckInView.swift @@ -5,13 +5,6 @@ struct MonthlyCheckInView: View { @Environment(\.openURL) private var openURL @EnvironmentObject var accountStore: AccountStore @StateObject private var viewModel = MonthlyCheckInViewModel() - - // Provides the latest snapshot date from CoreData (synced via iCloud) as fallback - // for lastCompletionDate when local MonthlyCheckInStore records are stale/missing. - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)], - animation: .none - ) private var latestSnapshots: FetchedResults @State private var referenceDate: Date @State private var monthlyNote: String @@ -32,12 +25,7 @@ struct MonthlyCheckInView: View { } private var lastCompletionDate: Date? { - // MonthlyCheckInStore lives in UserDefaults (device-local, not iCloud-synced). - // On a secondary device after iCloud sync the latest snapshot from CoreData may be - // newer than the local check-in record. Use whichever is more recent. - let localCompletion = MonthlyCheckInStore.latestCompletionDate() - let latestSnapshotDate = latestSnapshots.first?.date - return [localCompletion, latestSnapshotDate].compactMap { $0 }.max() + MonthlyCheckInStore.latestCompletionDate() } private var checkInProgress: Double { @@ -162,6 +150,11 @@ struct MonthlyCheckInView: View { starRating = MonthlyCheckInStore.rating(for: referenceDate) ?? 0 selectedMood = MonthlyCheckInStore.mood(for: referenceDate) } + .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in + monthlyNote = MonthlyCheckInStore.note(for: referenceDate) + starRating = MonthlyCheckInStore.rating(for: referenceDate) ?? 0 + selectedMood = MonthlyCheckInStore.mood(for: referenceDate) + } .onReceive(accountStore.$selectedAccount) { account in viewModel.selectedAccount = account viewModel.selectedRange = DateRange.month(containing: referenceDate)