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
+8 -8
View File
@@ -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;
@@ -0,0 +1,38 @@
import Foundation
import CoreData
@objc(JournalEntry)
public class JournalEntry: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<JournalEntry> {
return NSFetchRequest<JournalEntry>(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 }
}
}
@@ -102,4 +102,13 @@
<attribute name="value" optional="YES" attributeType="Decimal"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
</entity>
<entity name="JournalEntry" representedClassName="JournalEntry" syncable="YES">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="monthKey" optional="YES" attributeType="String"/>
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="moodRaw" optional="YES" attributeType="String"/>
<attribute name="rating" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
<attribute name="completionTime" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
</model>
@@ -186,6 +186,7 @@ class CoreDataStack: ObservableObject {
}
DispatchQueue.main.async {
self?.isLoaded = true
MonthlyCheckInStore.migrateIfNeeded()
}
}
@@ -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
}
}
@@ -709,8 +709,7 @@ class ChartsViewModel: ObservableObject {
private func completedMonthKeys(
sources: [InvestmentSource],
snapshots: [Snapshot],
after cutoff: Date
snapshots: [Snapshot]
) -> Set<DateComponents> {
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]] {
@@ -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)