feat(gamification): add streak engine, focus score, badge definitions, and GamificationStore
This commit is contained in:
parent
a86ed05271
commit
f6208e65d1
345
ios/ChronoMind/Shared/Gamification/GamificationEngine.swift
Normal file
345
ios/ChronoMind/Shared/Gamification/GamificationEngine.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user