InvestmentTrackerApp/PortfolioJournal/Utilities/MonthlyCheckInStore.swift

456 lines
16 KiB
Swift

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