Skip to main content

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, LessonLibrary};
8use chrono::{DateTime, Datelike, NaiveDate, Timelike, Utc, Weekday};
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::sync::OnceLock;
12
13/// Lesson counts per difficulty for the built-in lesson library, computed once.
14///
15/// Used by the no-argument achievement APIs; callers that load user lessons
16/// should prefer the `*_with_library` variants for accurate totals.
17fn builtin_difficulty_counts() -> &'static HashMap<Difficulty, usize> {
18    static COUNTS: OnceLock<HashMap<Difficulty, usize>> = OnceLock::new();
19    COUNTS.get_or_init(|| LessonLibrary::new().difficulty_counts())
20}
21
22/// Comprehensive user statistics and progress tracking
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct UserStats {
25    /// Set of completed lesson IDs
26    pub lessons_completed: HashSet<String>,
27    /// Count of lessons completed by difficulty level
28    pub lessons_by_difficulty: HashMap<Difficulty, usize>,
29    /// Set of unique commands that have been used
30    pub commands_used: HashSet<String>,
31    /// Total number of commands executed (including duplicates)
32    pub total_commands_executed: usize,
33    /// Current consecutive days streak
34    pub current_streak: usize,
35    /// Longest streak ever achieved
36    pub longest_streak: usize,
37    /// Last date the user was active
38    pub last_active_date: NaiveDate,
39    /// Total time spent in the app (seconds)
40    pub total_time_seconds: u64,
41    /// When the current session started
42    #[serde(skip)]
43    pub session_start: DateTime<Utc>,
44    /// User's achievement progress
45    pub achievements: UserAchievements,
46    /// Completed challenges (for exploration/challenge achievements)
47    pub completed_challenges: HashSet<String>,
48}
49
50impl UserStats {
51    /// Create a new user stats tracker
52    pub fn new() -> Self {
53        Self {
54            lessons_completed: HashSet::new(),
55            lessons_by_difficulty: HashMap::new(),
56            commands_used: HashSet::new(),
57            total_commands_executed: 0,
58            current_streak: 0,
59            longest_streak: 0,
60            last_active_date: Utc::now().date_naive(),
61            total_time_seconds: 0,
62            session_start: Utc::now(),
63            achievements: UserAchievements::new(),
64            completed_challenges: HashSet::new(),
65        }
66    }
67
68    /// Update the streak based on current date
69    pub fn update_streak(&mut self) {
70        let today = Utc::now().date_naive();
71        let days_since_last = (today - self.last_active_date).num_days();
72
73        match days_since_last {
74            0 => {
75                // Same day, streak continues
76            }
77            1 => {
78                // Next day, increment streak
79                self.current_streak += 1;
80                self.last_active_date = today;
81                if self.current_streak > self.longest_streak {
82                    self.longest_streak = self.current_streak;
83                }
84            }
85            _ => {
86                // Streak broken, reset to 1
87                self.current_streak = 1;
88                self.last_active_date = today;
89            }
90        }
91    }
92
93    /// Record completion of a lesson
94    pub fn record_lesson_completion(&mut self, lesson_id: String, difficulty: Difficulty) {
95        if self.lessons_completed.insert(lesson_id) {
96            // Only increment if this is a new completion
97            *self.lessons_by_difficulty.entry(difficulty).or_insert(0) += 1;
98        }
99    }
100
101    /// Record use of a command
102    pub fn record_command_use(&mut self, command: String) {
103        self.commands_used.insert(command);
104        self.total_commands_executed += 1;
105    }
106
107    /// Mark a challenge as completed
108    pub fn complete_challenge(&mut self, challenge_id: String) {
109        self.completed_challenges.insert(challenge_id);
110    }
111
112    /// Record a one-shot feature-usage event from the UI layer and return any
113    /// newly unlocked achievements.
114    ///
115    /// The TUI should call this at the corresponding feature-usage call sites.
116    /// Recognized event keys (each backs a `CompleteChallenge` achievement
117    /// condition, see `achievement::all_achievements`):
118    ///
119    /// - `"use_ai"`           — the AI assistant was invoked/toggled
120    /// - `"change_theme"`     — the user changed the color theme
121    /// - `"visit_all_panels"` — the user has visited every panel at least once
122    /// - `"use_context"`      — the context detection feature was used
123    /// - `"speed_lesson"`     — a lesson was completed in under 5 minutes
124    /// - `"perfect_lesson"`   — a lesson was completed without failed attempts
125    /// - `"first_challenge"`  — the user completed their first daily/weekly
126    ///   challenge (sent automatically by `ChallengeManager::check_command`)
127    ///
128    /// Calling this repeatedly with the same key is safe (idempotent).
129    pub fn record_event(&mut self, event_key: &str) -> Vec<Achievement> {
130        self.complete_challenge(event_key.to_string());
131        self.check_achievements()
132    }
133
134    /// Check all achievements against the built-in lesson library and return
135    /// newly unlocked ones.
136    ///
137    /// If user lessons have been loaded via `LessonLibrary::load_from_dir`,
138    /// prefer `check_achievements_with_library` so per-difficulty totals are
139    /// accurate.
140    pub fn check_achievements(&mut self) -> Vec<Achievement> {
141        self.check_achievements_with_counts(builtin_difficulty_counts())
142    }
143
144    /// Check all achievements using real per-difficulty lesson counts from
145    /// the given library, and return newly unlocked ones.
146    pub fn check_achievements_with_library(&mut self, library: &LessonLibrary) -> Vec<Achievement> {
147        self.check_achievements_with_counts(&library.difficulty_counts())
148    }
149
150    fn check_achievements_with_counts(
151        &mut self,
152        difficulty_totals: &HashMap<Difficulty, usize>,
153    ) -> Vec<Achievement> {
154        let mut newly_unlocked = Vec::new();
155
156        for achievement in all_achievements() {
157            // Skip if already unlocked
158            if self.achievements.is_unlocked(&achievement.id) {
159                continue;
160            }
161
162            // Check if condition is met
163            if self.check_unlock_condition(&achievement.condition, difficulty_totals) {
164                self.achievements.unlock(achievement.id.clone());
165                newly_unlocked.push(achievement);
166            }
167        }
168
169        newly_unlocked
170    }
171
172    /// Check if a specific unlock condition is met
173    fn check_unlock_condition(
174        &self,
175        condition: &UnlockCondition,
176        difficulty_totals: &HashMap<Difficulty, usize>,
177    ) -> bool {
178        match condition {
179            UnlockCondition::CompleteLesson(lesson_id) => {
180                self.lessons_completed.contains(lesson_id)
181            }
182            UnlockCondition::CompleteLessons(count) => {
183                self.lessons_completed.len() >= *count
184            }
185            UnlockCondition::CompleteAllDifficulty(difficulty) => {
186                let completed = self.lessons_by_difficulty.get(difficulty).unwrap_or(&0);
187                // Only unlockable when lessons of this difficulty actually
188                // exist; a difficulty with zero lessons never auto-unlocks.
189                match difficulty_totals.get(difficulty) {
190                    Some(&total) if total > 0 => *completed >= total,
191                    _ => false,
192                }
193            }
194            UnlockCondition::UseCommands(count) => {
195                self.commands_used.len() >= *count
196            }
197            UnlockCondition::MaintainStreak(days) => {
198                self.current_streak >= *days
199            }
200            UnlockCondition::CompleteChallenge(challenge_id) => {
201                self.completed_challenges.contains(challenge_id)
202            }
203            UnlockCondition::TimeOfDay { start, end } => {
204                let current_hour = Utc::now().hour() as u8;
205                if start < end {
206                    current_hour >= *start && current_hour < *end
207                } else {
208                    // Handles wrap-around (e.g., 22-2 for late night)
209                    current_hour >= *start || current_hour < *end
210                }
211            }
212            UnlockCondition::IsWeekend => {
213                let weekday = Utc::now().weekday();
214                weekday == Weekday::Sat || weekday == Weekday::Sun
215            }
216        }
217    }
218
219    /// Calculate completion percentage for a difficulty level, based on the
220    /// built-in lesson library. Use `completion_percentage_with_library` when
221    /// user lessons have been loaded.
222    pub fn completion_percentage(&self, difficulty: Difficulty) -> f32 {
223        self.completion_percentage_with_counts(difficulty, builtin_difficulty_counts())
224    }
225
226    /// Calculate completion percentage for a difficulty level using real
227    /// lesson counts from the given library.
228    pub fn completion_percentage_with_library(
229        &self,
230        difficulty: Difficulty,
231        library: &LessonLibrary,
232    ) -> f32 {
233        self.completion_percentage_with_counts(difficulty, &library.difficulty_counts())
234    }
235
236    fn completion_percentage_with_counts(
237        &self,
238        difficulty: Difficulty,
239        difficulty_totals: &HashMap<Difficulty, usize>,
240    ) -> f32 {
241        let completed = self.lessons_by_difficulty.get(&difficulty).unwrap_or(&0);
242        let total = *difficulty_totals.get(&difficulty).unwrap_or(&0);
243        if total == 0 {
244            return 0.0;
245        }
246        (*completed as f32 / total as f32) * 100.0
247    }
248
249    /// Get overall completion percentage across all lessons
250    pub fn overall_completion_percentage(&self, total_lessons: usize) -> f32 {
251        if total_lessons == 0 {
252            return 0.0;
253        }
254        (self.lessons_completed.len() as f32 / total_lessons as f32) * 100.0
255    }
256
257    /// Update total time spent so "Time Invested" accumulates.
258    ///
259    /// This is the TUI heartbeat API: call it periodically (e.g. once per
260    /// render tick or once a minute) AND on session end / before persisting
261    /// stats. Each call folds the elapsed time since `session_start` into
262    /// `total_time_seconds` and resets `session_start` to now, so calling it
263    /// at any frequency never double-counts time.
264    pub fn update_session_time(&mut self) {
265        let now = Utc::now();
266        let session_duration = (now - self.session_start).num_seconds();
267        if session_duration > 0 {
268            self.total_time_seconds += session_duration as u64;
269        }
270        self.session_start = now;
271    }
272
273    /// Get formatted time spent string (e.g., "2h 30m")
274    pub fn formatted_time_spent(&self) -> String {
275        let hours = self.total_time_seconds / 3600;
276        let minutes = (self.total_time_seconds % 3600) / 60;
277
278        if hours > 0 {
279            format!("{}h {}m", hours, minutes)
280        } else {
281            format!("{}m", minutes)
282        }
283    }
284
285    /// Get progress summary
286    pub fn progress_summary(&self) -> ProgressSummary {
287        ProgressSummary {
288            total_lessons_completed: self.lessons_completed.len(),
289            unique_commands_used: self.commands_used.len(),
290            total_commands_executed: self.total_commands_executed,
291            current_streak: self.current_streak,
292            longest_streak: self.longest_streak,
293            achievements_unlocked: self.achievements.total_unlocked(),
294            total_points: self.achievements.total_points(),
295            time_spent: self.formatted_time_spent(),
296        }
297    }
298
299    /// Get achievements that are close to being unlocked (for motivation)
300    pub fn nearly_unlocked_achievements(&self) -> Vec<(Achievement, f32)> {
301        let mut nearly_unlocked = Vec::new();
302
303        for achievement in all_achievements() {
304            if self.achievements.is_unlocked(&achievement.id) {
305                continue;
306            }
307
308            let progress = self.achievement_progress(&achievement.condition);
309            if progress >= 0.5 && progress < 1.0 {
310                nearly_unlocked.push((achievement, progress));
311            }
312        }
313
314        // Sort by progress (closest to completion first)
315        nearly_unlocked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
316        nearly_unlocked
317    }
318
319    /// Calculate progress towards an achievement (0.0 to 1.0)
320    fn achievement_progress(&self, condition: &UnlockCondition) -> f32 {
321        match condition {
322            UnlockCondition::CompleteLessons(target) => {
323                (self.lessons_completed.len() as f32 / *target as f32).min(1.0)
324            }
325            UnlockCondition::UseCommands(target) => {
326                (self.commands_used.len() as f32 / *target as f32).min(1.0)
327            }
328            UnlockCondition::MaintainStreak(target) => {
329                (self.current_streak as f32 / *target as f32).min(1.0)
330            }
331            UnlockCondition::CompleteLesson(_) |
332            UnlockCondition::CompleteAllDifficulty(_) |
333            UnlockCondition::CompleteChallenge(_) |
334            UnlockCondition::TimeOfDay { .. } |
335            UnlockCondition::IsWeekend => {
336                if self.check_unlock_condition(condition, builtin_difficulty_counts()) {
337                    1.0
338                } else {
339                    0.0
340                }
341            }
342        }
343    }
344}
345
346impl Default for UserStats {
347    fn default() -> Self {
348        Self::new()
349    }
350}
351
352/// Summary of user progress
353#[derive(Debug, Clone)]
354pub struct ProgressSummary {
355    pub total_lessons_completed: usize,
356    pub unique_commands_used: usize,
357    pub total_commands_executed: usize,
358    pub current_streak: usize,
359    pub longest_streak: usize,
360    pub achievements_unlocked: usize,
361    pub total_points: u32,
362    pub time_spent: String,
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_new_stats() {
371        let stats = UserStats::new();
372        assert_eq!(stats.lessons_completed.len(), 0);
373        assert_eq!(stats.current_streak, 0);
374        assert_eq!(stats.total_commands_executed, 0);
375    }
376
377    #[test]
378    fn test_record_lesson() {
379        let mut stats = UserStats::new();
380        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
381
382        assert_eq!(stats.lessons_completed.len(), 1);
383        assert_eq!(*stats.lessons_by_difficulty.get(&Difficulty::Beginner).unwrap(), 1);
384
385        // Recording same lesson again shouldn't increase count
386        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
387        assert_eq!(stats.lessons_completed.len(), 1);
388    }
389
390    #[test]
391    fn test_record_command() {
392        let mut stats = UserStats::new();
393        stats.record_command_use("ls".to_string());
394        stats.record_command_use("cd".to_string());
395        stats.record_command_use("ls".to_string()); // Duplicate
396
397        assert_eq!(stats.commands_used.len(), 2);
398        assert_eq!(stats.total_commands_executed, 3);
399    }
400
401    #[test]
402    fn test_check_achievements() {
403        let mut stats = UserStats::new();
404
405        // Complete first lesson
406        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
407
408        let unlocked = stats.check_achievements();
409        assert!(!unlocked.is_empty());
410        assert!(unlocked.iter().any(|a| a.id == "first_steps"));
411    }
412
413    #[test]
414    fn test_completion_percentage() {
415        let mut stats = UserStats::new();
416        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
417        stats.record_lesson_completion("lesson2".to_string(), Difficulty::Beginner);
418
419        let total_beginner = LessonLibrary::new()
420            .by_difficulty(Difficulty::Beginner)
421            .len();
422        let expected = (2.0 / total_beginner as f32) * 100.0;
423        let percentage = stats.completion_percentage(Difficulty::Beginner);
424        assert_eq!(percentage, expected);
425
426        // A difficulty with no available lessons reports 0%
427        assert_eq!(stats.completion_percentage(Difficulty::Expert), 0.0);
428    }
429
430    #[test]
431    fn test_complete_all_difficulty_uses_real_lesson_counts() {
432        let library = LessonLibrary::new();
433        let beginner_lessons: Vec<String> = library
434            .by_difficulty(Difficulty::Beginner)
435            .iter()
436            .map(|l| l.id.clone())
437            .collect();
438        assert!(!beginner_lessons.is_empty());
439
440        let mut stats = UserStats::new();
441
442        // Complete all but one beginner lesson: not yet unlocked
443        for id in &beginner_lessons[..beginner_lessons.len() - 1] {
444            stats.record_lesson_completion(id.clone(), Difficulty::Beginner);
445        }
446        stats.check_achievements_with_library(&library);
447        assert!(!stats.achievements.is_unlocked("beginner_complete"));
448
449        // Complete the last one: unlocked
450        stats.record_lesson_completion(
451            beginner_lessons.last().unwrap().clone(),
452            Difficulty::Beginner,
453        );
454        stats.check_achievements_with_library(&library);
455        assert!(stats.achievements.is_unlocked("beginner_complete"));
456
457        // Difficulties with zero available lessons never auto-unlock
458        assert!(!stats.achievements.is_unlocked("advanced_complete"));
459        assert!(!stats.achievements.is_unlocked("expert_complete"));
460    }
461
462    #[test]
463    fn test_navigation_master_matches_real_lesson_id() {
464        let mut stats = UserStats::new();
465        stats.record_lesson_completion("nav-basics".to_string(), Difficulty::Beginner);
466        stats.check_achievements();
467        assert!(stats.achievements.is_unlocked("navigation_master"));
468    }
469
470    #[test]
471    fn test_complete_lesson_achievement_ids_exist_in_library() {
472        // Guard against typos: every CompleteLesson condition must reference
473        // a real lesson id in the built-in library.
474        let library = LessonLibrary::new();
475        for achievement in all_achievements() {
476            if let UnlockCondition::CompleteLesson(lesson_id) = &achievement.condition {
477                assert!(
478                    library.get(lesson_id).is_some(),
479                    "Achievement '{}' references unknown lesson id '{}'",
480                    achievement.id,
481                    lesson_id
482                );
483            }
484        }
485    }
486
487    #[test]
488    fn test_record_event_unlocks_exploration_achievements() {
489        let mut stats = UserStats::new();
490
491        let unlocked = stats.record_event("use_ai");
492        assert!(unlocked.iter().any(|a| a.id == "ai_assistant"));
493
494        stats.record_event("change_theme");
495        assert!(stats.achievements.is_unlocked("theme_explorer"));
496
497        stats.record_event("visit_all_panels");
498        assert!(stats.achievements.is_unlocked("panel_master"));
499
500        stats.record_event("use_context");
501        assert!(stats.achievements.is_unlocked("context_aware"));
502
503        stats.record_event("speed_lesson");
504        assert!(stats.achievements.is_unlocked("speed_learner"));
505
506        stats.record_event("perfect_lesson");
507        assert!(stats.achievements.is_unlocked("perfect_lesson"));
508
509        stats.record_event("first_challenge");
510        assert!(stats.achievements.is_unlocked("challenge_accepted"));
511
512        // Idempotent: repeating an event doesn't re-unlock
513        let again = stats.record_event("use_ai");
514        assert!(again.is_empty());
515    }
516
517    #[test]
518    fn test_time_tracking() {
519        let mut stats = UserStats::new();
520        stats.total_time_seconds = 7200 + 1800; // 2h 30m
521
522        let formatted = stats.formatted_time_spent();
523        assert_eq!(formatted, "2h 30m");
524    }
525
526    #[test]
527    fn test_progress_summary() {
528        let mut stats = UserStats::new();
529        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
530        stats.record_command_use("ls".to_string());
531
532        let summary = stats.progress_summary();
533        assert_eq!(summary.total_lessons_completed, 1);
534        assert_eq!(summary.unique_commands_used, 1);
535    }
536
537    #[test]
538    fn test_challenge_completion() {
539        let mut stats = UserStats::new();
540        stats.complete_challenge("use_ai".to_string());
541
542        assert!(stats.completed_challenges.contains("use_ai"));
543    }
544}