import Foundation enum MonthlyCheckInStore { private static let notesKey = "monthlyCheckInNotes" private static let completionsKey = "monthlyCheckInCompletions" private static let legacyLastCheckInKey = "lastCheckInDate" private static let entriesKey = "monthlyCheckInEntries" // MARK: - Public Accessors static func note(for date: Date) -> String { entry(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? { entry(for: date)?.rating } 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 } } } static func mood(for date: Date) -> MonthlyCheckInMood? { entry(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 { Self.monthFormatter.string(from: date) } static func allNotes() -> [(date: Date, note: String)] { loadEntries() .compactMap { key, entry in guard let date = Self.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)] } 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) } .sorted { $0.date > $1.date } } static func completionDate(for date: Date) -> Date? { entry(for: date)?.completionDate } static func setCompletionDate(_ completionDate: Date, for month: Date) { updateEntry(for: month) { entry in entry.completionTime = completionDate.timeIntervalSince1970 } } 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) } 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 // Current streak counts consecutive on-time months up to the reference month. var currentStreak = 0 var cursor = referenceDate.startOfMonth while onTimeMonths.contains(cursor) { currentStreak += 1 cursor = cursor.adding(months: -1).startOfMonth } // Best streak across history. 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) } // MARK: - Private Helpers private static func updateEntry(for date: Date, mutate: (inout MonthlyCheckInEntry) -> 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 ) 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 if isEmpty { entries.removeValue(forKey: key) } else { entries[key] = entry } saveEntries(entries) persistLegacyMirrors(entries) } 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() } // Ensure legacy data is merged if it existed before this release. return mergeLegacy(into: decoded) } 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() 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 ) } 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] { 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 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] { guard let data = UserDefaults.standard.data(forKey: completionsKey), 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] } 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 } }