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; CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -498,7 +498,7 @@
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements; CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournal.entitlements;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -655,7 +655,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
@@ -688,7 +688,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
@@ -719,7 +719,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6; IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -743,7 +743,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6; IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -766,7 +766,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6; IPHONEOS_DEPLOYMENT_TARGET = 17.6;
@@ -789,7 +789,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32; CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = 2825Q76T7H; DEVELOPMENT_TEAM = 2825Q76T7H;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6; 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"/> <attribute name="value" optional="YES" attributeType="Decimal"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/> <relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="InvestmentSource" inverseName="snapshots" inverseEntity="InvestmentSource"/>
</entity> </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> </model>
@@ -186,6 +186,7 @@ class CoreDataStack: ObservableObject {
} }
DispatchQueue.main.async { DispatchQueue.main.async {
self?.isLoaded = true self?.isLoaded = true
MonthlyCheckInStore.migrateIfNeeded()
} }
} }
@@ -1,16 +1,27 @@
import Foundation import Foundation
import CoreData
// MonthlyCheckInStore primary storage is CoreData (syncs via iCloud).
// UserDefaults is kept for one-time migration from older builds.
enum MonthlyCheckInStore { enum MonthlyCheckInStore {
// Legacy UserDefaults keys (read-only after migration)
private static let notesKey = "monthlyCheckInNotes" private static let notesKey = "monthlyCheckInNotes"
private static let completionsKey = "monthlyCheckInCompletions" private static let completionsKey = "monthlyCheckInCompletions"
private static let legacyLastCheckInKey = "lastCheckInDate" private static let legacyLastCheckInKey = "lastCheckInDate"
private static let entriesKey = "monthlyCheckInEntries" 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 // MARK: - Public Accessors
static func note(for date: Date) -> String { 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) { static func setNote(_ note: String, for date: Date) {
@@ -21,21 +32,17 @@ enum MonthlyCheckInStore {
} }
static func rating(for date: Date) -> Int? { 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) { static func setRating(_ rating: Int?, for date: Date) {
updateEntry(for: date) { entry in updateEntry(for: date) { entry in
if let rating, rating > 0 { entry.ratingValue = rating
entry.rating = min(max(1, rating), 5)
} else {
entry.rating = nil
}
} }
} }
static func mood(for date: Date) -> MonthlyCheckInMood? { 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) { static func setMood(_ mood: MonthlyCheckInMood?, for date: Date) {
@@ -45,114 +52,98 @@ enum MonthlyCheckInStore {
} }
static func monthKey(for date: Date) -> String { 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)] { static func allNotes() -> [(date: Date, note: String)] {
loadEntries() fetchAllEntries()
.compactMap { key, entry in .compactMap { entry in
guard let date = Self.monthFormatter.date(from: key) else { return nil } guard let key = entry.monthKey,
let date = monthFormatter.date(from: key) else { return nil }
return (date: date, note: entry.note ?? "") return (date: date, note: entry.note ?? "")
} }
.sorted { $0.date > $1.date } .sorted { $0.date > $1.date }
} }
static func entry(for date: Date) -> MonthlyCheckInEntry? { 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)] { static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] {
loadEntries() fetchAllEntries()
.compactMap { key, entry in .compactMap { entry in
guard let date = Self.monthFormatter.date(from: key) else { return nil } guard let key = entry.monthKey,
return (date: date, entry: entry) let date = monthFormatter.date(from: key) else { return nil }
return (date: date, entry: makeCheckInEntry(entry))
} }
.sorted { $0.date > $1.date } .sorted { $0.date > $1.date }
} }
static func completionDate(for date: Date) -> 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) { static func setCompletionDate(_ completionDate: Date, for month: Date) {
let targetMonth = effectiveMonth(for: month, relativeTo: completionDate, graceDays: graceDays) let targetMonth = effectiveMonth(for: month, relativeTo: completionDate, graceDays: graceDays)
let targetKey = monthKey(for: targetMonth) let targetKey = monthFormatter.string(from: targetMonth)
var entries = loadEntries()
var didChange = false
let calendar = Calendar.current let calendar = Calendar.current
if calendar.isDate(month, inSameDayAs: completionDate), if calendar.isDate(month, inSameDayAs: completionDate),
calendar.component(.day, from: completionDate) > graceDays { 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 } let allExisting = fetchAllEntries()
guard entry.completionTime != nil else { return nil } let completedPrevious = allExisting.compactMap { entry -> (month: Date, entry: JournalEntry)? in
guard monthDate < targetMonth else { return nil } guard let key = entry.monthKey,
return (month: monthDate, entry: entry) 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 var cursor = lastCompleted.month.adding(months: 1).startOfMonth
while cursor < targetMonth { while cursor < targetMonth {
let key = monthFormatter.string(from: cursor) let key = monthFormatter.string(from: cursor)
if entries[key] == nil { if fetchEntry(for: key) == nil {
let fallbackCompletion = min(cursor.endOfMonth, completionDate) let fallbackDate = min(cursor.endOfMonth, completionDate)
let entry = MonthlyCheckInEntry( let new = JournalEntry(context: context)
note: lastCompleted.entry.note, new.monthKey = key
rating: lastCompleted.entry.rating, new.note = lastCompleted.entry.note
mood: lastCompleted.entry.mood, new.ratingValue = lastCompleted.entry.ratingValue
completionTime: fallbackCompletion.timeIntervalSince1970, new.mood = lastCompleted.entry.mood
createdAt: Date().timeIntervalSince1970 new.completionTime = fallbackDate
)
entries[key] = entry
didChange = true
} }
cursor = cursor.adding(months: 1).startOfMonth cursor = cursor.adding(months: 1).startOfMonth
} }
} }
} }
// Ensure the target month entry exists. // Ensure target month entry exists with the completion date.
var targetEntry = entries[targetKey] ?? MonthlyCheckInEntry( let targetEntry = fetchEntry(for: targetKey) ?? {
note: nil, let e = JournalEntry(context: context)
rating: nil, e.monthKey = targetKey
mood: nil, return e
completionTime: legacyCompletion(for: targetKey), }()
createdAt: Date().timeIntervalSince1970 targetEntry.completionTime = completionDate
)
targetEntry.completionTime = completionDate.timeIntervalSince1970
entries[targetKey] = targetEntry
didChange = true
// Mark all previous pending entries as completed as well. // Backfill any previous entries that have no completion date.
for (key, entry) in entries { for entry in fetchAllEntries() {
guard let entryMonth = monthFormatter.date(from: key)?.startOfMonth else { continue } guard let key = entry.monthKey,
guard entryMonth < targetMonth else { continue } let entryMonth = monthFormatter.date(from: key)?.startOfMonth,
guard entry.completionTime == nil else { continue } entryMonth < targetMonth,
entry.completionTime == nil else { continue }
var updated = entry entry.completionTime = min(entryMonth.endOfMonth, completionDate)
let fallbackCompletion = min(entryMonth.endOfMonth, completionDate)
updated.completionTime = fallbackCompletion.timeIntervalSince1970
entries[key] = updated
didChange = true
} }
guard didChange else { return } saveContext()
saveEntries(entries)
persistLegacyMirrors(entries)
} }
static func latestCompletionDate() -> Date? { static func latestCompletionDate() -> Date? {
let latestEntryDate = loadEntries().values let request = JournalEntry.fetchRequest()
.compactMap { $0.completionDate } request.predicate = NSPredicate(format: "completionTime != nil")
.max() request.sortDescriptors = [NSSortDescriptor(keyPath: \JournalEntry.completionTime, ascending: false)]
request.fetchLimit = 1
if let latestEntryDate { return (try? context.fetch(request))?.first?.completionTime
return latestEntryDate
}
let legacy = UserDefaults.standard.double(forKey: legacyLastCheckInKey)
guard legacy > 0 else { return nil }
return Date(timeIntervalSince1970: legacy)
} }
static func effectiveMonth( static func effectiveMonth(
@@ -192,7 +183,6 @@ enum MonthlyCheckInStore {
let totalCheckIns = completions.count let totalCheckIns = completions.count
let onTimeCount = onTimeMonths.count let onTimeCount = onTimeMonths.count
// Current streak counts consecutive on-time months up to the reference month.
var currentStreak = 0 var currentStreak = 0
var cursor = referenceDate.startOfMonth var cursor = referenceDate.startOfMonth
while onTimeMonths.contains(cursor) { while onTimeMonths.contains(cursor) {
@@ -200,7 +190,6 @@ enum MonthlyCheckInStore {
cursor = cursor.adding(months: -1).startOfMonth cursor = cursor.adding(months: -1).startOfMonth
} }
// Best streak across history.
let sortedMonths = onTimeMonths.sorted() let sortedMonths = onTimeMonths.sorted()
var bestStreak = 0 var bestStreak = 0
var running = 0 var running = 0
@@ -216,9 +205,7 @@ enum MonthlyCheckInStore {
} }
let averageDaysBeforeDeadline = onTimeCount > 0 let averageDaysBeforeDeadline = onTimeCount > 0
? deadlineDiffs ? deadlineDiffs.filter { $0 >= 0 }.average()
.filter { $0 >= 0 }
.average()
: nil : nil
let closestCutoffDays = onTimeCount > 0 let closestCutoffDays = onTimeCount > 0
? deadlineDiffs.filter { $0 >= 0 }.min() ? deadlineDiffs.filter { $0 >= 0 }.min()
@@ -252,6 +239,10 @@ enum MonthlyCheckInStore {
} }
static func clearAll() { static func clearAll() {
for entry in fetchAllEntries() {
context.delete(entry)
}
saveContext()
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
defaults.removeObject(forKey: notesKey) defaults.removeObject(forKey: notesKey)
defaults.removeObject(forKey: completionsKey) defaults.removeObject(forKey: completionsKey)
@@ -259,168 +250,129 @@ enum MonthlyCheckInStore {
defaults.removeObject(forKey: legacyLastCheckInKey) 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) let key = monthKey(for: date)
var entries = loadEntries() let entry = fetchEntry(for: key) ?? {
var entry = entries[key] ?? MonthlyCheckInEntry( let e = JournalEntry(context: context)
note: nil, e.monthKey = key
rating: nil, return e
mood: nil, }()
completionTime: legacyCompletion(for: key),
createdAt: Date().timeIntervalSince1970
)
mutate(&entry) mutate(entry)
if entry.note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true { if entry.note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true {
entry.note = nil 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 { if isEmpty {
entries.removeValue(forKey: key) context.delete(entry)
} else {
entries[key] = entry
} }
saveEntries(entries) saveContext()
persistLegacyMirrors(entries)
} }
private static func loadEntries() -> [String: MonthlyCheckInEntry] { private static func saveContext() {
guard let data = UserDefaults.standard.data(forKey: entriesKey), guard context.hasChanges else { return }
let decoded = try? JSONDecoder().decode([String: MonthlyCheckInEntry].self, from: data) else { try? context.save()
return migrateLegacyData() }
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
} }
return loadAndMergeLegacyKeys()
// Ensure legacy data is merged if it existed before this release.
return mergeLegacy(into: decoded)
} }
private static func saveEntries(_ entries: [String: MonthlyCheckInEntry]) { private static func loadAndMergeLegacyKeys() -> [String: MonthlyCheckInEntry] {
guard let data = try? JSONEncoder().encode(entries) else { return } let notes = loadLegacyNotes()
UserDefaults.standard.set(data, forKey: entriesKey) let completions = loadLegacyCompletions()
}
private static func migrateLegacyData() -> [String: MonthlyCheckInEntry] {
let notes = loadNotes()
let completions = loadCompletions()
guard !notes.isEmpty || !completions.isEmpty else { return [:] } guard !notes.isEmpty || !completions.isEmpty else { return [:] }
var entries: [String: MonthlyCheckInEntry] = [:] var entries: [String: MonthlyCheckInEntry] = [:]
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
for (key, note) in notes { for (key, note) in notes {
entries[key] = MonthlyCheckInEntry( entries[key] = MonthlyCheckInEntry(
note: note, note: note, rating: nil, mood: nil,
rating: nil, completionTime: completions[key], createdAt: now
mood: nil,
completionTime: completions[key],
createdAt: now
) )
} }
for (key, completion) in completions where entries[key] == nil { for (key, completion) in completions where entries[key] == nil {
entries[key] = MonthlyCheckInEntry( entries[key] = MonthlyCheckInEntry(
note: nil, note: nil, rating: nil, mood: nil,
rating: nil, completionTime: completion, createdAt: completion
mood: nil,
completionTime: completion,
createdAt: completion
) )
} }
saveEntries(entries)
return entries return entries
} }
private static func mergeLegacy(into entries: [String: MonthlyCheckInEntry]) -> [String: MonthlyCheckInEntry] { private static func loadLegacyNotes() -> [String: String] {
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] {
guard let data = UserDefaults.standard.data(forKey: notesKey), guard let data = UserDefaults.standard.data(forKey: notesKey),
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else { let decoded = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] }
return [:]
}
return decoded return decoded
} }
private static func saveNotes(_ notes: [String: String]) { private static func loadLegacyCompletions() -> [String: Double] {
guard let data = try? JSONEncoder().encode(notes) else { return }
UserDefaults.standard.set(data, forKey: notesKey)
}
private static func loadCompletions() -> [String: Double] {
guard let data = UserDefaults.standard.data(forKey: completionsKey), guard let data = UserDefaults.standard.data(forKey: completionsKey),
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else { let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else { return [:] }
return [:]
}
return decoded return decoded
} }
private static func saveCompletions(_ completions: [String: Double]) { // MARK: - Private Achievement Helpers
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]
}
private struct MonthlyCheckInAchievementRule { private struct MonthlyCheckInAchievementRule {
let achievement: MonthlyCheckInAchievement let achievement: MonthlyCheckInAchievement
@@ -474,9 +426,7 @@ enum MonthlyCheckInStore {
icon: "hourglass" icon: "hourglass"
), ),
isUnlocked: { _, _, _, _, closestCutoffDays, _ in isUnlocked: { _, _, _, _, closestCutoffDays, _ in
if let closestCutoffDays { if let closestCutoffDays { return closestCutoffDays <= 2 }
return closestCutoffDays <= 2
}
return false return false
} }
), ),
@@ -522,12 +472,8 @@ enum MonthlyCheckInStore {
) -> [MonthlyCheckInAchievement] { ) -> [MonthlyCheckInAchievement] {
achievementRules.compactMap { rule in achievementRules.compactMap { rule in
rule.isUnlocked( rule.isUnlocked(
currentStreak, currentStreak, bestStreak, onTimeCount, totalCheckIns,
bestStreak, closestCutoffDays, averageDaysBeforeDeadline
onTimeCount,
totalCheckIns,
closestCutoffDays,
averageDaysBeforeDeadline
) ? rule.achievement : nil ) ? rule.achievement : nil
} }
} }
@@ -709,8 +709,7 @@ class ChartsViewModel: ObservableObject {
private func completedMonthKeys( private func completedMonthKeys(
sources: [InvestmentSource], sources: [InvestmentSource],
snapshots: [Snapshot], snapshots: [Snapshot]
after cutoff: Date
) -> Set<DateComponents> { ) -> Set<DateComponents> {
let sourceIds = Set(sources.compactMap { $0.id }) let sourceIds = Set(sources.compactMap { $0.id })
guard !sourceIds.isEmpty else { return [] } guard !sourceIds.isEmpty else { return [] }
@@ -723,9 +722,8 @@ class ChartsViewModel: ObservableObject {
completed.reserveCapacity(groupedByMonth.count) completed.reserveCapacity(groupedByMonth.count)
for (key, monthSnapshots) in groupedByMonth { for (key, monthSnapshots) in groupedByMonth {
guard let monthDate = Calendar.current.date(from: key) else { continue } // A month is complete when all active sources have snapshot data for it.
guard monthDate > cutoff else { continue } // MonthlyCheckInStore (UserDefaults) is NOT synced via iCloud don't use it as a filter.
guard MonthlyCheckInStore.completionDate(for: monthDate) != nil else { continue }
let monthSourceIds = Set(monthSnapshots.compactMap { $0.source?.id }) let monthSourceIds = Set(monthSnapshots.compactMap { $0.source?.id })
if sourceIds.isSubset(of: monthSourceIds) { if sourceIds.isSubset(of: monthSourceIds) {
completed.insert(key) completed.insert(key)
@@ -739,24 +737,8 @@ class ChartsViewModel: ObservableObject {
sources: [InvestmentSource], sources: [InvestmentSource],
snapshots: [Snapshot] snapshots: [Snapshot]
) -> [Snapshot] { ) -> [Snapshot] {
guard let lastCompleted = MonthlyCheckInStore.latestCompletionDate()?.startOfMonth else { let completedKeys = completedMonthKeys(sources: sources, snapshots: snapshots)
return snapshots return snapshots.filter { completedKeys.contains(chartMonth(for: $0.date)) }
}
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)
}
} }
private func groupSnapshotsBySource(_ snapshots: [Snapshot]) -> [UUID: [Snapshot]] { private func groupSnapshotsBySource(_ snapshots: [Snapshot]) -> [UUID: [Snapshot]] {
@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import Charts import Charts
import CoreData
struct DashboardView: View { struct DashboardView: View {
@EnvironmentObject var iapService: IAPService @EnvironmentObject var iapService: IAPService
@@ -361,10 +362,7 @@ struct DashboardView: View {
if config.isCollapsed { if config.isCollapsed {
CompactCard(title: "Monthly Check-in", subtitle: "Last update: \(viewModel.formattedLastUpdate)") CompactCard(title: "Monthly Check-in", subtitle: "Last update: \(viewModel.formattedLastUpdate)")
} else { } else {
MonthlyCheckInCard( MonthlyCheckInCard(lastUpdated: viewModel.formattedLastUpdate)
lastUpdated: viewModel.formattedLastUpdate,
lastUpdatedDate: viewModel.portfolioSummary.lastUpdated
)
} }
case .momentumStreaks: case .momentumStreaks:
if config.isCollapsed { if config.isCollapsed {
@@ -594,15 +592,16 @@ struct TotalValueCard: View {
struct MonthlyCheckInCard: View { struct MonthlyCheckInCard: View {
let lastUpdated: String let lastUpdated: String
let lastUpdatedDate: Date?
@State private var startDestinationActive = false @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? { private var effectiveLastCheckInDate: Date? {
// Use whichever is more recent: local completion record or latest snapshot date from CoreData. latestJournalEntry.first?.completionTime
// 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()
} }
private var checkInProgress: Double { private var checkInProgress: Double {
@@ -762,14 +761,6 @@ struct MonthlyCheckInCard: View {
.background(Color(.systemBackground)) .background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius) .cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2) .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 { private var defaultReminderTime: Date {
@@ -895,7 +886,7 @@ struct MomentumStreaksCard: View {
.cornerRadius(AppConstants.UI.cornerRadius) .cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2) .shadow(color: .black.opacity(0.05), radius: 8, y: 2)
.onAppear(perform: refreshStats) .onAppear(perform: refreshStats)
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in
refreshStats() refreshStats()
} }
} }
@@ -968,7 +959,7 @@ struct MomentumStreaksCompactCard: View {
subtitle: "Streak: \(stats.currentStreak)x • Best: \(stats.bestStreak)x" subtitle: "Streak: \(stats.currentStreak)x • Best: \(stats.bestStreak)x"
) )
.onAppear(perform: refreshStats) .onAppear(perform: refreshStats)
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)) { _ in
refreshStats() refreshStats()
} }
} }
@@ -5,13 +5,6 @@ struct MonthlyCheckInView: View {
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@EnvironmentObject var accountStore: AccountStore @EnvironmentObject var accountStore: AccountStore
@StateObject private var viewModel = MonthlyCheckInViewModel() @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 referenceDate: Date
@State private var monthlyNote: String @State private var monthlyNote: String
@@ -32,12 +25,7 @@ struct MonthlyCheckInView: View {
} }
private var lastCompletionDate: Date? { private var lastCompletionDate: Date? {
// MonthlyCheckInStore lives in UserDefaults (device-local, not iCloud-synced). MonthlyCheckInStore.latestCompletionDate()
// 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()
} }
private var checkInProgress: Double { private var checkInProgress: Double {
@@ -162,6 +150,11 @@ struct MonthlyCheckInView: View {
starRating = MonthlyCheckInStore.rating(for: referenceDate) ?? 0 starRating = MonthlyCheckInStore.rating(for: referenceDate) ?? 0
selectedMood = MonthlyCheckInStore.mood(for: referenceDate) 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 .onReceive(accountStore.$selectedAccount) { account in
viewModel.selectedAccount = account viewModel.selectedAccount = account
viewModel.selectedRange = DateRange.month(containing: referenceDate) viewModel.selectedRange = DateRange.month(containing: referenceDate)