167b736aad
La tarjeta Momentum & Streaks mostraba "Streak 0x" casi todo el mes aunque la racha estuviera intacta: stats() empezaba a contar en el mes en curso y cortaba en el primer mes sin check-in on-time. El check-in del mes en curso está dentro de plazo hasta fin de mes (y con graceDays = 20 se hace normalmente ya entrado el mes siguiente), así que tenerlo pendiente no debe leerse como racha rota. Ahora el recuento arranca en el mes en curso si ya está hecho y, si no, en el anterior. Un mes anterior sin check-in sí rompe la racha, porque su plazo ya venció. Tests: 3 casos nuevos en MonthlyCheckInStoreTests (mes en curso pendiente, mes en curso completado, mes anterior perdido). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YcDn5ccuRFV83q7xWWokBT
496 lines
19 KiB
Swift
496 lines
19 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
|
|
|
|
// The current month's check-in is still within its deadline (end of the
|
|
// month), so having it pending must NOT read as a broken streak — that
|
|
// made the card show 0x for most of every month. Start counting at the
|
|
// current month when it is already done, otherwise at the previous one.
|
|
// Any earlier month that is missing has passed its deadline and does
|
|
// break the streak, so the walk backwards stays honest.
|
|
var currentStreak = 0
|
|
var cursor = referenceDate.startOfMonth
|
|
if !onTimeMonths.contains(cursor) {
|
|
cursor = cursor.adding(months: -1).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
|
|
}
|
|
}
|