feat(gamification): add streak engine, focus score, badge definitions, and GamificationStore

This commit is contained in:
saravanakumardb1 2026-02-27 22:13:39 -08:00
parent a86ed05271
commit f6208e65d1

View File

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