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:
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure legacy data is merged if it existed before this release.
|
private static func makeCheckInEntry(_ entry: JournalEntry) -> MonthlyCheckInEntry {
|
||||||
return mergeLegacy(into: decoded)
|
MonthlyCheckInEntry(
|
||||||
|
note: entry.note,
|
||||||
|
rating: entry.ratingValue,
|
||||||
|
mood: entry.mood,
|
||||||
|
completionTime: entry.completionTime?.timeIntervalSince1970,
|
||||||
|
createdAt: entry.createdAt?.timeIntervalSince1970 ?? Date().timeIntervalSince1970
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func saveEntries(_ entries: [String: MonthlyCheckInEntry]) {
|
// MARK: - Legacy UserDefaults Reader (for migration only)
|
||||||
guard let data = try? JSONEncoder().encode(entries) else { return }
|
|
||||||
UserDefaults.standard.set(data, forKey: entriesKey)
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func migrateLegacyData() -> [String: MonthlyCheckInEntry] {
|
private static func loadAndMergeLegacyKeys() -> [String: MonthlyCheckInEntry] {
|
||||||
let notes = loadNotes()
|
let notes = loadLegacyNotes()
|
||||||
let completions = loadCompletions()
|
let completions = loadLegacyCompletions()
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user