// ── Gamification Engine ─────────────────────────────────────── // Streaks, focus score, badges, weekly summary import Foundation // MARK: - Streak struct StreakData: Codable, Equatable { var currentStreak: Int // consecutive days with ≥1 completed timer var longestStreak: Int var lastActiveDate: String? // "yyyy-MM-dd" var streakStartDate: String? // "yyyy-MM-dd" var totalActiveDays: Int static let empty = StreakData( currentStreak: 0, longestStreak: 0, lastActiveDate: nil, streakStartDate: nil, totalActiveDays: 0 ) } // MARK: - Focus Score struct FocusScore: Equatable { let score: Int // 0-100 let onTimeRate: Double // 0-1 let focusHours: Double // total Pomodoro hours this week let completedCount: Int let dismissedCount: Int let avgSnoozeCount: Double let weekLabel: String // "Feb 24 – Mar 2" var grade: FocusGrade { switch score { case 90...100: return .excellent case 75..<90: return .great case 60..<75: return .good case 40..<60: return .fair default: return .needsWork } } } enum FocusGrade: String { case excellent = "Excellent" case great = "Great" case good = "Good" case fair = "Fair" case needsWork = "Needs Work" var emoji: String { switch self { case .excellent: return "🔥" case .great: return "⭐" case .good: return "👍" case .fair: return "📈" case .needsWork: return "💪" } } var color: String { switch self { case .excellent: return "success" case .great: return "accent" case .good: return "standard" case .fair: return "important" case .needsWork: return "critical" } } } // MARK: - Badge struct Badge: Identifiable, Equatable { let id: String let title: String let description: String let icon: String let requirement: Int // streak days required let tier: BadgeTier var isEarned: Bool = false var earnedDate: Date? static func == (lhs: Badge, rhs: Badge) -> Bool { lhs.id == rhs.id } } enum BadgeTier: String, Codable, CaseIterable { case bronze case silver case gold case platinum case diamond } // MARK: - Badge Definitions enum BadgeDefinitions { static let allBadges: [Badge] = [ Badge(id: "streak_3", title: "Getting Started", description: "3-day streak", icon: "flame", requirement: 3, tier: .bronze), Badge(id: "streak_7", title: "Week Warrior", description: "7-day streak", icon: "flame.fill", requirement: 7, tier: .bronze), Badge(id: "streak_14", title: "Two-Week Titan", description: "14-day streak", icon: "star", requirement: 14, tier: .silver), Badge(id: "streak_30", title: "Monthly Master", description: "30-day streak", icon: "star.fill", requirement: 30, tier: .silver), Badge(id: "streak_60", title: "Double Down", description: "60-day streak", icon: "trophy", requirement: 60, tier: .gold), Badge(id: "streak_100", title: "Century Club", description: "100-day streak", icon: "trophy.fill", requirement: 100, tier: .gold), Badge(id: "streak_200", title: "Unstoppable", description: "200-day streak", icon: "crown", requirement: 200, tier: .platinum), Badge(id: "streak_365", title: "Year of Focus", description: "365-day streak", icon: "crown.fill", requirement: 365, tier: .diamond), ] static func earnedBadges(for streakDays: Int) -> [Badge] { allBadges.map { badge in var b = badge b.isEarned = streakDays >= badge.requirement return b } } static func nextBadge(for streakDays: Int) -> Badge? { allBadges.first { streakDays < $0.requirement } } static func justEarnedBadge(previousStreak: Int, newStreak: Int) -> Badge? { allBadges.first { previousStreak < $0.requirement && newStreak >= $0.requirement } } } // MARK: - Gamification Engine enum GamificationEngine { private static let dateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd" return f }() // MARK: - Streak Calculation /// Update streak based on a timer completion static func updateStreak(current: StreakData, completedAt: Date) -> StreakData { var updated = current let todayStr = dateFormatter.string(from: completedAt) // Already counted today if updated.lastActiveDate == todayStr { return updated } let calendar = Calendar.current let today = calendar.startOfDay(for: completedAt) if let lastDateStr = updated.lastActiveDate, let lastDate = dateFormatter.date(from: lastDateStr) { let lastDay = calendar.startOfDay(for: lastDate) let daysBetween = calendar.dateComponents([.day], from: lastDay, to: today).day ?? 0 if daysBetween == 1 { // Consecutive day — extend streak updated.currentStreak += 1 } else if daysBetween > 1 { // Streak broken — restart updated.currentStreak = 1 updated.streakStartDate = todayStr } // daysBetween == 0 handled above (same day) } else { // First ever completion updated.currentStreak = 1 updated.streakStartDate = todayStr } updated.lastActiveDate = todayStr updated.totalActiveDays += 1 updated.longestStreak = max(updated.longestStreak, updated.currentStreak) return updated } /// Check if streak is still valid (not broken by missing yesterday) static func validateStreak(current: StreakData, now: Date = Date()) -> StreakData { guard let lastDateStr = current.lastActiveDate, let lastDate = dateFormatter.date(from: lastDateStr) else { return current } let calendar = Calendar.current let today = calendar.startOfDay(for: now) let lastDay = calendar.startOfDay(for: lastDate) let daysBetween = calendar.dateComponents([.day], from: lastDay, to: today).day ?? 0 // If more than 1 day has passed without activity, streak is broken if daysBetween > 1 { var broken = current broken.currentStreak = 0 broken.streakStartDate = nil return broken } return current } // MARK: - Focus Score Calculation /// Calculate weekly focus score from timers static func calculateFocusScore(timers: [CMTimer], now: Date = Date()) -> FocusScore { let calendar = Calendar.current let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now))! let weekEnd = calendar.date(byAdding: .day, value: 7, to: weekStart)! // Filter timers that were active this week let weekTimers = timers.filter { timer in let endDate = timer.completedAt ?? timer.dismissedAt ?? timer.firedAt ?? timer.createdAt return endDate >= weekStart && endDate < weekEnd } let completed = weekTimers.filter { $0.state == .completed }.count let dismissed = weekTimers.filter { $0.state == .dismissed }.count let total = completed + dismissed // On-time rate (completed / total finished) let onTimeRate = total > 0 ? Double(completed) / Double(total) : 0 // Focus hours (Pomodoro work time) let pomodoroTimers = weekTimers.filter { $0.type == .pomodoro && $0.state == .completed } let focusSeconds = pomodoroTimers.reduce(0.0) { sum, timer in sum + (timer.duration ?? 0) } let focusHours = focusSeconds / 3600 // Average snooze count let avgSnooze = total > 0 ? Double(weekTimers.reduce(0) { $0 + $1.snoozeCount }) / Double(total) : 0 // Score formula: // 40% on-time rate + 30% focus hours (capped at 4h/week) + 20% consistency + 10% low snooze let onTimeScore = onTimeRate * 40 let focusScore = min(focusHours / 4.0, 1.0) * 30 let consistencyScore = min(Double(total) / 10.0, 1.0) * 20 // 10+ timers/week = full marks let snoozeScore = max(1.0 - (avgSnooze / 3.0), 0) * 10 // 0 snoozes = full marks let totalScore = Int(onTimeScore + focusScore + consistencyScore + snoozeScore) // Week label let fmt = DateFormatter() fmt.dateFormat = "MMM d" let weekLabel = "\(fmt.string(from: weekStart)) – \(fmt.string(from: weekEnd.addingTimeInterval(-86400)))" return FocusScore( score: min(100, totalScore), onTimeRate: onTimeRate, focusHours: focusHours, completedCount: completed, dismissedCount: dismissed, avgSnoozeCount: avgSnooze, weekLabel: weekLabel ) } // MARK: - Weekly Summary Data struct WeeklySummary { let focusScore: FocusScore let streak: StreakData let badges: [Badge] let topCategory: String? let totalTimers: Int let pomodoroSessions: Int } static func generateWeeklySummary( timers: [CMTimer], streak: StreakData, now: Date = Date() ) -> WeeklySummary { let score = calculateFocusScore(timers: timers, now: now) let badges = BadgeDefinitions.earnedBadges(for: streak.currentStreak).filter(\.isEarned) // Top category this week let calendar = Calendar.current let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now))! let weekTimers = timers.filter { timer in let date = timer.completedAt ?? timer.createdAt return date >= weekStart } let categoryGroups = Dictionary(grouping: weekTimers) { $0.category ?? "Uncategorized" } let topCategory = categoryGroups.max(by: { $0.value.count < $1.value.count })?.key let pomodoroSessions = weekTimers.filter { $0.type == .pomodoro && $0.state == .completed }.count return WeeklySummary( focusScore: score, streak: streak, badges: badges, topCategory: topCategory, totalTimers: weekTimers.count, pomodoroSessions: pomodoroSessions ) } } // MARK: - Persistence final class GamificationStore: ObservableObject { static let shared = GamificationStore() @Published var streak: StreakData @Published var newBadge: Badge? private let persistenceKey = "chronomind-streak" private init() { if let data = UserDefaults.standard.data(forKey: persistenceKey), let saved = try? JSONDecoder().decode(StreakData.self, from: data) { streak = saved } else { streak = .empty } // Validate on launch streak = GamificationEngine.validateStreak(current: streak) save() } func recordCompletion(at date: Date = Date()) { let previousStreak = streak.currentStreak streak = GamificationEngine.updateStreak(current: streak, completedAt: date) save() // Check for new badge if let badge = BadgeDefinitions.justEarnedBadge(previousStreak: previousStreak, newStreak: streak.currentStreak) { newBadge = badge } } func clearNewBadge() { newBadge = nil } private func save() { guard let data = try? JSONEncoder().encode(streak) else { return } UserDefaults.standard.set(data, forKey: persistenceKey) } }