1use 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
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}
49
50impl UserStats {
51 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 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 }
77 1 => {
78 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 self.current_streak = 1;
88 self.last_active_date = today;
89 }
90 }
91 }
92
93 pub fn record_lesson_completion(&mut self, lesson_id: String, difficulty: Difficulty) {
95 if self.lessons_completed.insert(lesson_id) {
96 *self.lessons_by_difficulty.entry(difficulty).or_insert(0) += 1;
98 }
99 }
100
101 pub fn record_command_use(&mut self, command: String) {
103 self.commands_used.insert(command);
104 self.total_commands_executed += 1;
105 }
106
107 pub fn complete_challenge(&mut self, challenge_id: String) {
109 self.completed_challenges.insert(challenge_id);
110 }
111
112 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 pub fn check_achievements(&mut self) -> Vec<Achievement> {
141 self.check_achievements_with_counts(builtin_difficulty_counts())
142 }
143
144 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 if self.achievements.is_unlocked(&achievement.id) {
159 continue;
160 }
161
162 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 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 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 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 pub fn completion_percentage(&self, difficulty: Difficulty) -> f32 {
223 self.completion_percentage_with_counts(difficulty, builtin_difficulty_counts())
224 }
225
226 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 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 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 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 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 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 nearly_unlocked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
316 nearly_unlocked
317 }
318
319 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#[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 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()); 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 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 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 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 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 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 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 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; 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}