Files
InvestmentTrackerApp/PortfolioJournal/Utilities/MonthlyCheckInStore.swift
T
alexandrev-tibco 270edeb536 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>
2026-05-26 15:05:53 +02:00

487 lines
18 KiB
Swift

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 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 {
fetchEntry(for: monthKey(for: date))?.note ?? ""
}
static func setNote(_ note: String, for date: Date) {
updateEntry(for: date) { entry in
let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines)
entry.note = trimmed.isEmpty ? nil : note
}
}
static func rating(for date: Date) -> Int? {
fetchEntry(for: monthKey(for: date))?.ratingValue
}
static func setRating(_ rating: Int?, for date: Date) {
updateEntry(for: date) { entry in
entry.ratingValue = rating
}
}
static func mood(for date: Date) -> MonthlyCheckInMood? {
fetchEntry(for: monthKey(for: date))?.mood
}
static func setMood(_ mood: MonthlyCheckInMood?, for date: Date) {
updateEntry(for: date) { entry in
entry.mood = mood
}
}
static func monthKey(for date: Date) -> String {
monthFormatter.string(from: effectiveMonth(for: date))
}
static func allNotes() -> [(date: Date, note: String)] {
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? {
fetchEntry(for: monthKey(for: date)).map(makeCheckInEntry)
}
static func allEntries() -> [(date: Date, entry: MonthlyCheckInEntry)] {
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? {
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 = monthFormatter.string(from: targetMonth)
let calendar = Calendar.current
if calendar.isDate(month, inSameDayAs: completionDate),
calendar.component(.day, from: completionDate) > graceDays {
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 = 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 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 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
// 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)
}
saveContext()
}
static func latestCompletionDate() -> Date? {
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(
for date: Date,
relativeTo referenceDate: Date = Date(),
graceDays: Int = 20
) -> Date {
let calendar = Calendar.current
if calendar.isDate(date, inSameDayAs: referenceDate) {
let day = calendar.component(.day, from: referenceDate)
if day <= graceDays {
return referenceDate.adding(months: -1).startOfMonth
}
}
return date.startOfMonth
}
static func stats(referenceDate: Date = Date()) -> MonthlyCheckInStats {
let cutoff = referenceDate.endOfMonth
let entries = allEntries().filter { $0.date <= cutoff }
let completions: [(month: Date, completion: Date, mood: MonthlyCheckInMood?)] = entries.compactMap { entry in
guard let completion = entry.entry.completionDate else { return nil }
return (month: entry.date.startOfMonth, completion: completion, mood: entry.entry.mood)
}
guard !completions.isEmpty else { return .empty }
let deadlineDiffs = completions.map { item -> Double in
let deadline = item.month.endOfMonth
return deadline.timeIntervalSince(item.completion) / 86_400
}
let onTimeCompletions = completions.filter { item in
item.completion <= item.month.endOfMonth
}
let onTimeMonths = Set(onTimeCompletions.map { $0.month })
let totalCheckIns = completions.count
let onTimeCount = onTimeMonths.count
var currentStreak = 0
var cursor = referenceDate.startOfMonth
while onTimeMonths.contains(cursor) {
currentStreak += 1
cursor = cursor.adding(months: -1).startOfMonth
}
let sortedMonths = onTimeMonths.sorted()
var bestStreak = 0
var running = 0
var previousMonth: Date?
for month in sortedMonths {
if let previousMonth, month == previousMonth.adding(months: 1).startOfMonth {
running += 1
} else {
running = 1
}
bestStreak = max(bestStreak, running)
previousMonth = month
}
let averageDaysBeforeDeadline = onTimeCount > 0
? deadlineDiffs.filter { $0 >= 0 }.average()
: nil
let closestCutoffDays = onTimeCount > 0
? deadlineDiffs.filter { $0 >= 0 }.min()
: nil
let recentMood = completions.sorted { $0.month > $1.month }.first?.mood
let achievements = buildAchievements(
currentStreak: currentStreak,
bestStreak: bestStreak,
onTimeCount: onTimeCount,
totalCheckIns: totalCheckIns,
closestCutoffDays: closestCutoffDays,
averageDaysBeforeDeadline: averageDaysBeforeDeadline
)
return MonthlyCheckInStats(
currentStreak: currentStreak,
bestStreak: bestStreak,
onTimeCount: onTimeCount,
totalCheckIns: totalCheckIns,
averageDaysBeforeDeadline: averageDaysBeforeDeadline,
closestCutoffDays: closestCutoffDays,
recentMood: recentMood,
achievements: achievements
)
}
static func achievementStatuses(referenceDate: Date = Date()) -> [MonthlyCheckInAchievementStatus] {
let stats = stats(referenceDate: referenceDate)
return achievementStatuses(for: stats)
}
static func clearAll() {
for entry in fetchAllEntries() {
context.delete(entry)
}
saveContext()
let defaults = UserDefaults.standard
defaults.removeObject(forKey: notesKey)
defaults.removeObject(forKey: completionsKey)
defaults.removeObject(forKey: entriesKey)
defaults.removeObject(forKey: legacyLastCheckInKey)
}
// MARK: - One-time Migration from UserDefaults
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 entry = fetchEntry(for: key) ?? {
let e = JournalEntry(context: context)
e.monthKey = key
return e
}()
mutate(entry)
if entry.note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true {
entry.note = nil
}
let isEmpty = entry.note == nil
&& entry.ratingValue == nil
&& entry.mood == nil
&& entry.completionTime == nil
if isEmpty {
context.delete(entry)
}
saveContext()
}
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
}
return loadAndMergeLegacyKeys()
}
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
)
}
for (key, completion) in completions where entries[key] == nil {
entries[key] = MonthlyCheckInEntry(
note: nil, rating: nil, mood: nil,
completionTime: completion, createdAt: completion
)
}
return entries
}
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 [:] }
return decoded
}
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 [:] }
return decoded
}
// MARK: - Private Achievement Helpers
private struct MonthlyCheckInAchievementRule {
let achievement: MonthlyCheckInAchievement
let isUnlocked: (Int, Int, Int, Int, Double?, Double?) -> Bool
}
private static let achievementRules: [MonthlyCheckInAchievementRule] = [
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "streak_3",
title: String(localized: "achievement_streak_3_title"),
detail: String(localized: "achievement_streak_3_detail"),
icon: "flame.fill"
),
isUnlocked: { currentStreak, _, _, _, _, _ in currentStreak >= 3 }
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "streak_6",
title: String(localized: "achievement_streak_6_title"),
detail: String(localized: "achievement_streak_6_detail"),
icon: "bolt.heart.fill"
),
isUnlocked: { currentStreak, _, _, _, _, _ in currentStreak >= 6 }
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "streak_12",
title: String(localized: "achievement_streak_12_title"),
detail: String(localized: "achievement_streak_12_detail"),
icon: "calendar.circle.fill"
),
isUnlocked: { _, bestStreak, _, _, _, _ in bestStreak >= 12 }
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "perfect_on_time",
title: String(localized: "achievement_perfect_on_time_title"),
detail: String(localized: "achievement_perfect_on_time_detail"),
icon: "checkmark.seal.fill"
),
isUnlocked: { _, _, onTimeCount, totalCheckIns, _, _ in
onTimeCount == totalCheckIns && totalCheckIns >= 3
}
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "clutch_finish",
title: String(localized: "achievement_clutch_finish_title"),
detail: String(localized: "achievement_clutch_finish_detail"),
icon: "hourglass"
),
isUnlocked: { _, _, _, _, closestCutoffDays, _ in
if let closestCutoffDays { return closestCutoffDays <= 2 }
return false
}
),
MonthlyCheckInAchievementRule(
achievement: MonthlyCheckInAchievement(
key: "early_bird",
title: String(localized: "achievement_early_bird_title"),
detail: String(localized: "achievement_early_bird_detail"),
icon: "sun.max.fill"
),
isUnlocked: { _, _, _, totalCheckIns, _, averageDaysBeforeDeadline in
if let averageDaysBeforeDeadline {
return averageDaysBeforeDeadline >= 10 && totalCheckIns >= 3
}
return false
}
)
]
private static func achievementStatuses(for stats: MonthlyCheckInStats) -> [MonthlyCheckInAchievementStatus] {
achievementRules.map { rule in
MonthlyCheckInAchievementStatus(
achievement: rule.achievement,
isUnlocked: rule.isUnlocked(
stats.currentStreak,
stats.bestStreak,
stats.onTimeCount,
stats.totalCheckIns,
stats.closestCutoffDays,
stats.averageDaysBeforeDeadline
)
)
}
}
private static func buildAchievements(
currentStreak: Int,
bestStreak: Int,
onTimeCount: Int,
totalCheckIns: Int,
closestCutoffDays: Double?,
averageDaysBeforeDeadline: Double?
) -> [MonthlyCheckInAchievement] {
achievementRules.compactMap { rule in
rule.isUnlocked(
currentStreak, bestStreak, onTimeCount, totalCheckIns,
closestCutoffDays, averageDaysBeforeDeadline
) ? rule.achievement : nil
}
}
private static var monthFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
return formatter
}
}