1use crate::achievement::{Achievement, UnlockCondition, UserAchievements, all_achievements};
7use crate::lesson::{Difficulty, LessonLibrary, Track};
8use chrono::{DateTime, Datelike, Local, NaiveDate, Timelike, Utc, Weekday};
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::sync::OnceLock;
12
13fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct UserStats {
25 pub lessons_completed: HashSet<String>,
27 pub lessons_by_difficulty: HashMap<Difficulty, usize>,
29 pub commands_used: HashSet<String>,
31 pub total_commands_executed: usize,
33 pub current_streak: usize,
35 pub longest_streak: usize,
37 pub last_active_date: NaiveDate,
39 pub total_time_seconds: u64,
41 #[serde(skip)]
43 pub session_start: DateTime<Utc>,
44 pub achievements: UserAchievements,
46 pub completed_challenges: HashSet<String>,
48 #[serde(default)]
51 pub lessons_mastered: HashSet<String>,
52}
53
54pub const REST_DAYS: i64 = 2;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum StreakStatus {
61 NotStarted,
63 LearnedToday { days: usize },
65 Resting { days: usize, rest_days_left: i64 },
67 WelcomeBack { best: usize },
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct TrackProgress {
74 pub track: Track,
75 pub total: usize,
77 pub completed: Vec<String>,
79 pub remaining: Vec<String>,
81 pub mastered: usize,
83}
84
85impl TrackProgress {
86 pub fn is_complete(&self) -> bool {
88 self.total > 0 && self.remaining.is_empty()
89 }
90
91 pub fn is_mastered(&self) -> bool {
93 self.is_complete() && self.mastered == self.total
94 }
95}
96
97pub fn local_today() -> NaiveDate {
99 Local::now().date_naive()
100}
101
102impl UserStats {
103 pub fn new() -> Self {
105 Self {
106 lessons_completed: HashSet::new(),
107 lessons_by_difficulty: HashMap::new(),
108 commands_used: HashSet::new(),
109 total_commands_executed: 0,
110 current_streak: 0,
111 longest_streak: 0,
112 last_active_date: local_today(),
113 total_time_seconds: 0,
114 session_start: Utc::now(),
115 achievements: UserAchievements::new(),
116 completed_challenges: HashSet::new(),
117 lessons_mastered: HashSet::new(),
118 }
119 }
120
121 pub fn update_streak(&mut self) {
124 self.record_learning_day(local_today());
125 }
126
127 pub fn record_learning_day(&mut self, today: NaiveDate) {
131 let gap = (today - self.last_active_date).num_days();
132 if self.current_streak == 0 {
133 self.current_streak = 1;
134 } else if gap <= 0 {
135 return;
137 } else if gap <= REST_DAYS + 1 {
138 self.current_streak += 1;
139 } else {
140 self.current_streak = 1;
141 }
142 self.last_active_date = today;
143 self.longest_streak = self.longest_streak.max(self.current_streak);
144 }
145
146 pub fn settle_streak(&mut self, today: NaiveDate) {
150 if self.current_streak > 0 && (today - self.last_active_date).num_days() > REST_DAYS + 1 {
151 self.current_streak = 0;
152 }
153 }
154
155 pub fn streak_status(&self, today: NaiveDate) -> StreakStatus {
157 let gap = (today - self.last_active_date).num_days();
158 if self.current_streak == 0 {
159 if self.longest_streak > 0 {
160 StreakStatus::WelcomeBack { best: self.longest_streak }
161 } else {
162 StreakStatus::NotStarted
163 }
164 } else if gap <= 0 {
165 StreakStatus::LearnedToday { days: self.current_streak }
166 } else if gap <= REST_DAYS + 1 {
167 StreakStatus::Resting {
168 days: self.current_streak,
169 rest_days_left: REST_DAYS + 1 - gap,
170 }
171 } else {
172 StreakStatus::WelcomeBack { best: self.longest_streak }
173 }
174 }
175
176 pub fn record_lesson_mastered(&mut self, lesson_id: String) {
178 self.lessons_mastered.insert(lesson_id);
179 }
180
181 pub fn track_progress(&self, library: &LessonLibrary, track: Track) -> TrackProgress {
183 let mut progress = TrackProgress {
184 track,
185 total: 0,
186 completed: Vec::new(),
187 remaining: Vec::new(),
188 mastered: 0,
189 };
190 for lesson in library.all().into_iter().filter(|l| l.track == track) {
191 progress.total += 1;
192 if self.lessons_completed.contains(&lesson.id) {
193 progress.completed.push(lesson.title.clone());
194 if self.lessons_mastered.contains(&lesson.id) {
195 progress.mastered += 1;
196 }
197 } else {
198 progress.remaining.push(lesson.title.clone());
199 }
200 }
201 progress
202 }
203
204 pub fn record_lesson_completion(&mut self, lesson_id: String, difficulty: Difficulty) {
206 if self.lessons_completed.insert(lesson_id) {
207 *self.lessons_by_difficulty.entry(difficulty).or_insert(0) += 1;
209 }
210 }
211
212 pub fn record_command_use(&mut self, command: String) {
214 self.commands_used.insert(command);
215 self.total_commands_executed += 1;
216 }
217
218 pub fn complete_challenge(&mut self, challenge_id: String) {
220 self.completed_challenges.insert(challenge_id);
221 }
222
223 pub fn record_event(&mut self, event_key: &str) -> Vec<Achievement> {
241 self.complete_challenge(event_key.to_string());
242 self.check_achievements()
243 }
244
245 pub fn check_achievements(&mut self) -> Vec<Achievement> {
252 self.check_achievements_with_counts(builtin_difficulty_counts())
253 }
254
255 pub fn check_achievements_with_library(&mut self, library: &LessonLibrary) -> Vec<Achievement> {
258 self.check_achievements_with_counts(&library.difficulty_counts())
259 }
260
261 fn check_achievements_with_counts(
262 &mut self,
263 difficulty_totals: &HashMap<Difficulty, usize>,
264 ) -> Vec<Achievement> {
265 let mut newly_unlocked = Vec::new();
266
267 for achievement in all_achievements() {
268 if self.achievements.is_unlocked(&achievement.id) {
270 continue;
271 }
272
273 if self.check_unlock_condition(&achievement.condition, difficulty_totals) {
275 self.achievements.unlock(achievement.id.clone());
276 newly_unlocked.push(achievement);
277 }
278 }
279
280 newly_unlocked
281 }
282
283 fn check_unlock_condition(
285 &self,
286 condition: &UnlockCondition,
287 difficulty_totals: &HashMap<Difficulty, usize>,
288 ) -> bool {
289 match condition {
290 UnlockCondition::CompleteLesson(lesson_id) => {
291 self.lessons_completed.contains(lesson_id)
292 }
293 UnlockCondition::CompleteLessons(count) => {
294 self.lessons_completed.len() >= *count
295 }
296 UnlockCondition::CompleteAllDifficulty(difficulty) => {
297 let completed = self.lessons_by_difficulty.get(difficulty).unwrap_or(&0);
298 match difficulty_totals.get(difficulty) {
301 Some(&total) if total > 0 => *completed >= total,
302 _ => false,
303 }
304 }
305 UnlockCondition::UseCommands(count) => {
306 self.commands_used.len() >= *count
307 }
308 UnlockCondition::MaintainStreak(days) => {
309 self.current_streak >= *days
310 }
311 UnlockCondition::CompleteChallenge(challenge_id) => {
312 self.completed_challenges.contains(challenge_id)
313 }
314 UnlockCondition::TimeOfDay { start, end } => {
315 let current_hour = Local::now().hour() as u8;
316 if start < end {
317 current_hour >= *start && current_hour < *end
318 } else {
319 current_hour >= *start || current_hour < *end
321 }
322 }
323 UnlockCondition::IsWeekend => {
324 let weekday = Local::now().weekday();
325 weekday == Weekday::Sat || weekday == Weekday::Sun
326 }
327 }
328 }
329
330 pub fn completion_percentage(&self, difficulty: Difficulty) -> f32 {
334 self.completion_percentage_with_counts(difficulty, builtin_difficulty_counts())
335 }
336
337 pub fn completion_percentage_with_library(
340 &self,
341 difficulty: Difficulty,
342 library: &LessonLibrary,
343 ) -> f32 {
344 self.completion_percentage_with_counts(difficulty, &library.difficulty_counts())
345 }
346
347 fn completion_percentage_with_counts(
348 &self,
349 difficulty: Difficulty,
350 difficulty_totals: &HashMap<Difficulty, usize>,
351 ) -> f32 {
352 let completed = self.lessons_by_difficulty.get(&difficulty).unwrap_or(&0);
353 let total = *difficulty_totals.get(&difficulty).unwrap_or(&0);
354 if total == 0 {
355 return 0.0;
356 }
357 (*completed as f32 / total as f32) * 100.0
358 }
359
360 pub fn overall_completion_percentage(&self, total_lessons: usize) -> f32 {
362 if total_lessons == 0 {
363 return 0.0;
364 }
365 (self.lessons_completed.len() as f32 / total_lessons as f32) * 100.0
366 }
367
368 pub fn update_session_time(&mut self) {
376 let now = Utc::now();
377 let session_duration = (now - self.session_start).num_seconds();
378 if session_duration > 0 {
379 self.total_time_seconds += session_duration as u64;
380 }
381 self.session_start = now;
382 }
383
384 pub fn formatted_time_spent(&self) -> String {
386 let hours = self.total_time_seconds / 3600;
387 let minutes = (self.total_time_seconds % 3600) / 60;
388
389 if hours > 0 {
390 format!("{}h {}m", hours, minutes)
391 } else {
392 format!("{}m", minutes)
393 }
394 }
395
396 pub fn progress_summary(&self) -> ProgressSummary {
398 ProgressSummary {
399 total_lessons_completed: self.lessons_completed.len(),
400 unique_commands_used: self.commands_used.len(),
401 total_commands_executed: self.total_commands_executed,
402 current_streak: self.current_streak,
403 longest_streak: self.longest_streak,
404 achievements_unlocked: self.achievements.total_unlocked(),
405 total_points: self.achievements.total_points(),
406 time_spent: self.formatted_time_spent(),
407 }
408 }
409
410 pub fn nearly_unlocked_achievements(&self) -> Vec<(Achievement, f32)> {
412 let mut nearly_unlocked = Vec::new();
413
414 for achievement in all_achievements() {
415 if self.achievements.is_unlocked(&achievement.id) {
416 continue;
417 }
418
419 let progress = self.achievement_progress(&achievement.condition);
420 if progress >= 0.5 && progress < 1.0 {
421 nearly_unlocked.push((achievement, progress));
422 }
423 }
424
425 nearly_unlocked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
427 nearly_unlocked
428 }
429
430 fn achievement_progress(&self, condition: &UnlockCondition) -> f32 {
432 match condition {
433 UnlockCondition::CompleteLessons(target) => {
434 (self.lessons_completed.len() as f32 / *target as f32).min(1.0)
435 }
436 UnlockCondition::UseCommands(target) => {
437 (self.commands_used.len() as f32 / *target as f32).min(1.0)
438 }
439 UnlockCondition::MaintainStreak(target) => {
440 (self.current_streak as f32 / *target as f32).min(1.0)
441 }
442 UnlockCondition::CompleteLesson(_) |
443 UnlockCondition::CompleteAllDifficulty(_) |
444 UnlockCondition::CompleteChallenge(_) |
445 UnlockCondition::TimeOfDay { .. } |
446 UnlockCondition::IsWeekend => {
447 if self.check_unlock_condition(condition, builtin_difficulty_counts()) {
448 1.0
449 } else {
450 0.0
451 }
452 }
453 }
454 }
455}
456
457impl Default for UserStats {
458 fn default() -> Self {
459 Self::new()
460 }
461}
462
463#[derive(Debug, Clone)]
465pub struct ProgressSummary {
466 pub total_lessons_completed: usize,
467 pub unique_commands_used: usize,
468 pub total_commands_executed: usize,
469 pub current_streak: usize,
470 pub longest_streak: usize,
471 pub achievements_unlocked: usize,
472 pub total_points: u32,
473 pub time_spent: String,
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[test]
481 fn test_new_stats() {
482 let stats = UserStats::new();
483 assert_eq!(stats.lessons_completed.len(), 0);
484 assert_eq!(stats.current_streak, 0);
485 assert_eq!(stats.total_commands_executed, 0);
486 }
487
488 #[test]
489 fn test_record_lesson() {
490 let mut stats = UserStats::new();
491 stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
492
493 assert_eq!(stats.lessons_completed.len(), 1);
494 assert_eq!(*stats.lessons_by_difficulty.get(&Difficulty::Beginner).unwrap(), 1);
495
496 stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
498 assert_eq!(stats.lessons_completed.len(), 1);
499 }
500
501 #[test]
502 fn test_record_command() {
503 let mut stats = UserStats::new();
504 stats.record_command_use("ls".to_string());
505 stats.record_command_use("cd".to_string());
506 stats.record_command_use("ls".to_string()); assert_eq!(stats.commands_used.len(), 2);
509 assert_eq!(stats.total_commands_executed, 3);
510 }
511
512 #[test]
513 fn test_check_achievements() {
514 let mut stats = UserStats::new();
515
516 stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
518
519 let unlocked = stats.check_achievements();
520 assert!(!unlocked.is_empty());
521 assert!(unlocked.iter().any(|a| a.id == "first_steps"));
522 }
523
524 #[test]
525 fn test_completion_percentage() {
526 let mut stats = UserStats::new();
527 stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
528 stats.record_lesson_completion("lesson2".to_string(), Difficulty::Beginner);
529
530 let total_beginner = LessonLibrary::new()
531 .by_difficulty(Difficulty::Beginner)
532 .len();
533 let expected = (2.0 / total_beginner as f32) * 100.0;
534 let percentage = stats.completion_percentage(Difficulty::Beginner);
535 assert_eq!(percentage, expected);
536
537 assert_eq!(stats.completion_percentage(Difficulty::Expert), 0.0);
539 }
540
541 #[test]
542 fn test_complete_all_difficulty_uses_real_lesson_counts() {
543 let library = LessonLibrary::new();
544 let beginner_lessons: Vec<String> = library
545 .by_difficulty(Difficulty::Beginner)
546 .iter()
547 .map(|l| l.id.clone())
548 .collect();
549 assert!(!beginner_lessons.is_empty());
550
551 let mut stats = UserStats::new();
552
553 for id in &beginner_lessons[..beginner_lessons.len() - 1] {
555 stats.record_lesson_completion(id.clone(), Difficulty::Beginner);
556 }
557 stats.check_achievements_with_library(&library);
558 assert!(!stats.achievements.is_unlocked("beginner_complete"));
559
560 stats.record_lesson_completion(
562 beginner_lessons.last().unwrap().clone(),
563 Difficulty::Beginner,
564 );
565 stats.check_achievements_with_library(&library);
566 assert!(stats.achievements.is_unlocked("beginner_complete"));
567
568 assert!(!stats.achievements.is_unlocked("advanced_complete"));
570 assert!(!stats.achievements.is_unlocked("expert_complete"));
571 }
572
573 #[test]
574 fn test_navigation_master_matches_real_lesson_id() {
575 let mut stats = UserStats::new();
576 stats.record_lesson_completion("nav-basics".to_string(), Difficulty::Beginner);
577 stats.check_achievements();
578 assert!(stats.achievements.is_unlocked("navigation_master"));
579 }
580
581 #[test]
582 fn test_complete_lesson_achievement_ids_exist_in_library() {
583 let library = LessonLibrary::new();
586 for achievement in all_achievements() {
587 if let UnlockCondition::CompleteLesson(lesson_id) = &achievement.condition {
588 assert!(
589 library.get(lesson_id).is_some(),
590 "Achievement '{}' references unknown lesson id '{}'",
591 achievement.id,
592 lesson_id
593 );
594 }
595 }
596 }
597
598 #[test]
599 fn test_record_event_unlocks_exploration_achievements() {
600 let mut stats = UserStats::new();
601
602 let unlocked = stats.record_event("use_ai");
603 assert!(unlocked.iter().any(|a| a.id == "ai_assistant"));
604
605 stats.record_event("change_theme");
606 assert!(stats.achievements.is_unlocked("theme_explorer"));
607
608 stats.record_event("visit_all_panels");
609 assert!(stats.achievements.is_unlocked("panel_master"));
610
611 stats.record_event("use_context");
612 assert!(stats.achievements.is_unlocked("context_aware"));
613
614 stats.record_event("speed_lesson");
615 assert!(stats.achievements.is_unlocked("speed_learner"));
616
617 stats.record_event("perfect_lesson");
618 assert!(stats.achievements.is_unlocked("perfect_lesson"));
619
620 stats.record_event("first_challenge");
621 assert!(stats.achievements.is_unlocked("challenge_accepted"));
622
623 let again = stats.record_event("use_ai");
625 assert!(again.is_empty());
626 }
627
628 #[test]
629 fn test_time_tracking() {
630 let mut stats = UserStats::new();
631 stats.total_time_seconds = 7200 + 1800; let formatted = stats.formatted_time_spent();
634 assert_eq!(formatted, "2h 30m");
635 }
636
637 #[test]
638 fn test_progress_summary() {
639 let mut stats = UserStats::new();
640 stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
641 stats.record_command_use("ls".to_string());
642
643 let summary = stats.progress_summary();
644 assert_eq!(summary.total_lessons_completed, 1);
645 assert_eq!(summary.unique_commands_used, 1);
646 }
647
648 fn day(n: u32) -> NaiveDate {
649 NaiveDate::from_ymd_opt(2026, 10, n).unwrap()
650 }
651
652 #[test]
653 fn first_learning_day_counts() {
654 let mut stats = UserStats::new();
655 stats.last_active_date = day(1);
656 stats.record_learning_day(day(1));
657 assert_eq!(stats.current_streak, 1);
658 assert_eq!(stats.streak_status(day(1)), StreakStatus::LearnedToday { days: 1 });
659 }
660
661 #[test]
662 fn rest_days_keep_the_streak() {
663 let mut stats = UserStats::new();
664 stats.last_active_date = day(1);
665 stats.record_learning_day(day(1));
666 stats.record_learning_day(day(1)); stats.record_learning_day(day(2));
668 assert_eq!(stats.current_streak, 2);
669
670 assert_eq!(
672 stats.streak_status(day(4)),
673 StreakStatus::Resting { days: 2, rest_days_left: 1 }
674 );
675 assert_eq!(
677 stats.streak_status(day(5)),
678 StreakStatus::Resting { days: 2, rest_days_left: 0 }
679 );
680 stats.settle_streak(day(5));
681 assert_eq!(stats.current_streak, 2);
682 stats.record_learning_day(day(5));
683 assert_eq!(stats.current_streak, 3);
684 assert_eq!(stats.longest_streak, 3);
685 }
686
687 #[test]
688 fn a_long_break_starts_a_new_run_and_keeps_the_best() {
689 let mut stats = UserStats::new();
690 stats.last_active_date = day(1);
691 for d in 1..=4 {
692 stats.record_learning_day(day(d));
693 }
694 assert_eq!(stats.current_streak, 4);
695
696 stats.settle_streak(day(8));
698 assert_eq!(stats.current_streak, 0);
699 assert_eq!(stats.streak_status(day(8)), StreakStatus::WelcomeBack { best: 4 });
700 stats.record_learning_day(day(8));
701 assert_eq!(stats.current_streak, 1);
702 assert_eq!(stats.longest_streak, 4);
703 }
704
705 #[test]
706 fn clock_moving_back_changes_nothing() {
707 let mut stats = UserStats::new();
708 stats.last_active_date = day(5);
709 stats.record_learning_day(day(5));
710 stats.record_learning_day(day(6));
711 stats.record_learning_day(day(3));
712 assert_eq!(stats.current_streak, 2);
713 assert_eq!(stats.last_active_date, day(6));
714 }
715
716 #[test]
717 fn track_progress_counts_completion_and_mastery() {
718 let library = LessonLibrary::new();
719 let explorer: Vec<_> = library
720 .all()
721 .into_iter()
722 .filter(|l| l.track == Track::Explorer)
723 .map(|l| l.id.clone())
724 .collect();
725 assert!(explorer.len() > 1);
726
727 let mut stats = UserStats::new();
728 let fresh = stats.track_progress(&library, Track::Explorer);
729 assert_eq!(fresh.total, explorer.len());
730 assert!(fresh.completed.is_empty());
731 assert!(!fresh.is_complete());
732
733 for id in &explorer {
734 stats.lessons_completed.insert(id.clone());
735 }
736 stats.record_lesson_mastered(explorer[0].clone());
737 let done = stats.track_progress(&library, Track::Explorer);
738 assert!(done.is_complete());
739 assert!(!done.is_mastered());
740 assert_eq!(done.mastered, 1);
741
742 for id in &explorer {
743 stats.record_lesson_mastered(id.clone());
744 }
745 assert!(stats.track_progress(&library, Track::Explorer).is_mastered());
746 assert!(!stats.track_progress(&library, Track::Operator).is_complete());
748 }
749
750 #[test]
751 fn old_save_files_without_mastery_still_load() {
752 let mut value = serde_json::to_value(UserStats::new()).unwrap();
753 value.as_object_mut().unwrap().remove("lessons_mastered");
754 let stats: UserStats = serde_json::from_value(value).unwrap();
755 assert!(stats.lessons_mastered.is_empty());
756 }
757
758 #[test]
759 fn test_challenge_completion() {
760 let mut stats = UserStats::new();
761 stats.complete_challenge("use_ai".to_string());
762
763 assert!(stats.completed_challenges.contains("use_ai"));
764 }
765}