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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
+9
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user