From f6208e65d1f8ab42ba56249d884d0b79a46d81b8 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:13:39 -0800 Subject: [PATCH] feat(gamification): add streak engine, focus score, badge definitions, and GamificationStore --- .../Gamification/GamificationEngine.swift | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 ios/ChronoMind/Shared/Gamification/GamificationEngine.swift diff --git a/ios/ChronoMind/Shared/Gamification/GamificationEngine.swift b/ios/ChronoMind/Shared/Gamification/GamificationEngine.swift new file mode 100644 index 0000000..2a9cfdb --- /dev/null +++ b/ios/ChronoMind/Shared/Gamification/GamificationEngine.swift @@ -0,0 +1,345 @@ +// ── 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) + } +}