Migrate journal entries to CoreData for iCloud sync (build 33)

- 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 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-05-26 15:05:53 +02:00
parent 0b01d6f563
commit 270edeb536
8 changed files with 236 additions and 276 deletions
@@ -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<JournalEntry>
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()
}
}
@@ -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<Snapshot>
@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)