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 } }