270edeb536
- 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>
487 lines
18 KiB
Swift
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
|
|
}
|
|
}
|