346 lines
12 KiB
Swift
346 lines
12 KiB
Swift
// ── 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)
|
||
}
|
||
}
|