arct_core/
stats.rs

1//! User statistics and progress tracking
2//!
3//! This module tracks user progress, maintains learning streaks, and
4//! manages achievement unlocking logic.
5
6use crate::achievement::{Achievement, UnlockCondition, UserAchievements, all_achievements};
7use crate::lesson::Difficulty;
8use chrono::{DateTime, Datelike, NaiveDate, Timelike, Utc, Weekday};
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12/// Comprehensive user statistics and progress tracking
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct UserStats {
15    /// Set of completed lesson IDs
16    pub lessons_completed: HashSet<String>,
17    /// Count of lessons completed by difficulty level
18    pub lessons_by_difficulty: HashMap<Difficulty, usize>,
19    /// Set of unique commands that have been used
20    pub commands_used: HashSet<String>,
21    /// Total number of commands executed (including duplicates)
22    pub total_commands_executed: usize,
23    /// Current consecutive days streak
24    pub current_streak: usize,
25    /// Longest streak ever achieved
26    pub longest_streak: usize,
27    /// Last date the user was active
28    pub last_active_date: NaiveDate,
29    /// Total time spent in the app (seconds)
30    pub total_time_seconds: u64,
31    /// When the current session started
32    #[serde(skip)]
33    pub session_start: DateTime<Utc>,
34    /// User's achievement progress
35    pub achievements: UserAchievements,
36    /// Completed challenges (for exploration/challenge achievements)
37    pub completed_challenges: HashSet<String>,
38}
39
40impl UserStats {
41    /// Create a new user stats tracker
42    pub fn new() -> Self {
43        Self {
44            lessons_completed: HashSet::new(),
45            lessons_by_difficulty: HashMap::new(),
46            commands_used: HashSet::new(),
47            total_commands_executed: 0,
48            current_streak: 0,
49            longest_streak: 0,
50            last_active_date: Utc::now().date_naive(),
51            total_time_seconds: 0,
52            session_start: Utc::now(),
53            achievements: UserAchievements::new(),
54            completed_challenges: HashSet::new(),
55        }
56    }
57
58    /// Update the streak based on current date
59    pub fn update_streak(&mut self) {
60        let today = Utc::now().date_naive();
61        let days_since_last = (today - self.last_active_date).num_days();
62
63        match days_since_last {
64            0 => {
65                // Same day, streak continues
66            }
67            1 => {
68                // Next day, increment streak
69                self.current_streak += 1;
70                self.last_active_date = today;
71                if self.current_streak > self.longest_streak {
72                    self.longest_streak = self.current_streak;
73                }
74            }
75            _ => {
76                // Streak broken, reset to 1
77                self.current_streak = 1;
78                self.last_active_date = today;
79            }
80        }
81    }
82
83    /// Record completion of a lesson
84    pub fn record_lesson_completion(&mut self, lesson_id: String, difficulty: Difficulty) {
85        if self.lessons_completed.insert(lesson_id) {
86            // Only increment if this is a new completion
87            *self.lessons_by_difficulty.entry(difficulty).or_insert(0) += 1;
88        }
89    }
90
91    /// Record use of a command
92    pub fn record_command_use(&mut self, command: String) {
93        self.commands_used.insert(command);
94        self.total_commands_executed += 1;
95    }
96
97    /// Mark a challenge as completed
98    pub fn complete_challenge(&mut self, challenge_id: String) {
99        self.completed_challenges.insert(challenge_id);
100    }
101
102    /// Check all achievements and return newly unlocked ones
103    pub fn check_achievements(&mut self) -> Vec<Achievement> {
104        let mut newly_unlocked = Vec::new();
105
106        for achievement in all_achievements() {
107            // Skip if already unlocked
108            if self.achievements.is_unlocked(&achievement.id) {
109                continue;
110            }
111
112            // Check if condition is met
113            if self.check_unlock_condition(&achievement.condition) {
114                self.achievements.unlock(achievement.id.clone());
115                newly_unlocked.push(achievement);
116            }
117        }
118
119        newly_unlocked
120    }
121
122    /// Check if a specific unlock condition is met
123    fn check_unlock_condition(&self, condition: &UnlockCondition) -> bool {
124        match condition {
125            UnlockCondition::CompleteLesson(lesson_id) => {
126                self.lessons_completed.contains(lesson_id)
127            }
128            UnlockCondition::CompleteLessons(count) => {
129                self.lessons_completed.len() >= *count
130            }
131            UnlockCondition::CompleteAllDifficulty(difficulty) => {
132                // This would need to know total lessons per difficulty
133                // For now, we'll return false and implement this when we have lesson data
134                // In production, you'd compare against total available lessons
135                let completed = self.lessons_by_difficulty.get(difficulty).unwrap_or(&0);
136                // Placeholder: assume 10 lessons per difficulty for now
137                *completed >= 10
138            }
139            UnlockCondition::UseCommands(count) => {
140                self.commands_used.len() >= *count
141            }
142            UnlockCondition::MaintainStreak(days) => {
143                self.current_streak >= *days
144            }
145            UnlockCondition::CompleteChallenge(challenge_id) => {
146                self.completed_challenges.contains(challenge_id)
147            }
148            UnlockCondition::TimeOfDay { start, end } => {
149                let current_hour = Utc::now().hour() as u8;
150                if start < end {
151                    current_hour >= *start && current_hour < *end
152                } else {
153                    // Handles wrap-around (e.g., 22-2 for late night)
154                    current_hour >= *start || current_hour < *end
155                }
156            }
157            UnlockCondition::IsWeekend => {
158                let weekday = Utc::now().weekday();
159                weekday == Weekday::Sat || weekday == Weekday::Sun
160            }
161        }
162    }
163
164    /// Calculate completion percentage for a difficulty level
165    ///
166    /// Note: This requires knowing total lessons available per difficulty.
167    /// For now, returns a placeholder calculation.
168    pub fn completion_percentage(&self, difficulty: Difficulty) -> f32 {
169        let completed = self.lessons_by_difficulty.get(&difficulty).unwrap_or(&0);
170        // Placeholder: assume 10 lessons per difficulty
171        // In production, this should query the actual lesson library
172        let total = 10.0;
173        (*completed as f32 / total) * 100.0
174    }
175
176    /// Get overall completion percentage across all lessons
177    pub fn overall_completion_percentage(&self, total_lessons: usize) -> f32 {
178        if total_lessons == 0 {
179            return 0.0;
180        }
181        (self.lessons_completed.len() as f32 / total_lessons as f32) * 100.0
182    }
183
184    /// Update total time spent (call this periodically or on session end)
185    pub fn update_session_time(&mut self) {
186        let now = Utc::now();
187        let session_duration = (now - self.session_start).num_seconds();
188        if session_duration > 0 {
189            self.total_time_seconds += session_duration as u64;
190        }
191        self.session_start = now;
192    }
193
194    /// Get formatted time spent string (e.g., "2h 30m")
195    pub fn formatted_time_spent(&self) -> String {
196        let hours = self.total_time_seconds / 3600;
197        let minutes = (self.total_time_seconds % 3600) / 60;
198
199        if hours > 0 {
200            format!("{}h {}m", hours, minutes)
201        } else {
202            format!("{}m", minutes)
203        }
204    }
205
206    /// Get progress summary
207    pub fn progress_summary(&self) -> ProgressSummary {
208        ProgressSummary {
209            total_lessons_completed: self.lessons_completed.len(),
210            unique_commands_used: self.commands_used.len(),
211            total_commands_executed: self.total_commands_executed,
212            current_streak: self.current_streak,
213            longest_streak: self.longest_streak,
214            achievements_unlocked: self.achievements.total_unlocked(),
215            total_points: self.achievements.total_points(),
216            time_spent: self.formatted_time_spent(),
217        }
218    }
219
220    /// Get achievements that are close to being unlocked (for motivation)
221    pub fn nearly_unlocked_achievements(&self) -> Vec<(Achievement, f32)> {
222        let mut nearly_unlocked = Vec::new();
223
224        for achievement in all_achievements() {
225            if self.achievements.is_unlocked(&achievement.id) {
226                continue;
227            }
228
229            let progress = self.achievement_progress(&achievement.condition);
230            if progress >= 0.5 && progress < 1.0 {
231                nearly_unlocked.push((achievement, progress));
232            }
233        }
234
235        // Sort by progress (closest to completion first)
236        nearly_unlocked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
237        nearly_unlocked
238    }
239
240    /// Calculate progress towards an achievement (0.0 to 1.0)
241    fn achievement_progress(&self, condition: &UnlockCondition) -> f32 {
242        match condition {
243            UnlockCondition::CompleteLessons(target) => {
244                (self.lessons_completed.len() as f32 / *target as f32).min(1.0)
245            }
246            UnlockCondition::UseCommands(target) => {
247                (self.commands_used.len() as f32 / *target as f32).min(1.0)
248            }
249            UnlockCondition::MaintainStreak(target) => {
250                (self.current_streak as f32 / *target as f32).min(1.0)
251            }
252            UnlockCondition::CompleteLesson(_) |
253            UnlockCondition::CompleteAllDifficulty(_) |
254            UnlockCondition::CompleteChallenge(_) |
255            UnlockCondition::TimeOfDay { .. } |
256            UnlockCondition::IsWeekend => {
257                if self.check_unlock_condition(condition) { 1.0 } else { 0.0 }
258            }
259        }
260    }
261}
262
263impl Default for UserStats {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269/// Summary of user progress
270#[derive(Debug, Clone)]
271pub struct ProgressSummary {
272    pub total_lessons_completed: usize,
273    pub unique_commands_used: usize,
274    pub total_commands_executed: usize,
275    pub current_streak: usize,
276    pub longest_streak: usize,
277    pub achievements_unlocked: usize,
278    pub total_points: u32,
279    pub time_spent: String,
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_new_stats() {
288        let stats = UserStats::new();
289        assert_eq!(stats.lessons_completed.len(), 0);
290        assert_eq!(stats.current_streak, 0);
291        assert_eq!(stats.total_commands_executed, 0);
292    }
293
294    #[test]
295    fn test_record_lesson() {
296        let mut stats = UserStats::new();
297        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
298
299        assert_eq!(stats.lessons_completed.len(), 1);
300        assert_eq!(*stats.lessons_by_difficulty.get(&Difficulty::Beginner).unwrap(), 1);
301
302        // Recording same lesson again shouldn't increase count
303        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
304        assert_eq!(stats.lessons_completed.len(), 1);
305    }
306
307    #[test]
308    fn test_record_command() {
309        let mut stats = UserStats::new();
310        stats.record_command_use("ls".to_string());
311        stats.record_command_use("cd".to_string());
312        stats.record_command_use("ls".to_string()); // Duplicate
313
314        assert_eq!(stats.commands_used.len(), 2);
315        assert_eq!(stats.total_commands_executed, 3);
316    }
317
318    #[test]
319    fn test_check_achievements() {
320        let mut stats = UserStats::new();
321
322        // Complete first lesson
323        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
324
325        let unlocked = stats.check_achievements();
326        assert!(!unlocked.is_empty());
327        assert!(unlocked.iter().any(|a| a.id == "first_steps"));
328    }
329
330    #[test]
331    fn test_completion_percentage() {
332        let mut stats = UserStats::new();
333        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
334        stats.record_lesson_completion("lesson2".to_string(), Difficulty::Beginner);
335
336        let percentage = stats.completion_percentage(Difficulty::Beginner);
337        assert_eq!(percentage, 20.0); // 2 out of 10 (placeholder)
338    }
339
340    #[test]
341    fn test_time_tracking() {
342        let mut stats = UserStats::new();
343        stats.total_time_seconds = 7200 + 1800; // 2h 30m
344
345        let formatted = stats.formatted_time_spent();
346        assert_eq!(formatted, "2h 30m");
347    }
348
349    #[test]
350    fn test_progress_summary() {
351        let mut stats = UserStats::new();
352        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
353        stats.record_command_use("ls".to_string());
354
355        let summary = stats.progress_summary();
356        assert_eq!(summary.total_lessons_completed, 1);
357        assert_eq!(summary.unique_commands_used, 1);
358    }
359
360    #[test]
361    fn test_challenge_completion() {
362        let mut stats = UserStats::new();
363        stats.complete_challenge("use_ai".to_string());
364
365        assert!(stats.completed_challenges.contains("use_ai"));
366    }
367}