learning_ai_clock/ios/ChronoMind/Shared/Gamification/GamificationEngine.swift

346 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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