1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct UserStats {
15 pub lessons_completed: HashSet<String>,
17 pub lessons_by_difficulty: HashMap<Difficulty, usize>,
19 pub commands_used: HashSet<String>,
21 pub total_commands_executed: usize,
23 pub current_streak: usize,
25 pub longest_streak: usize,
27 pub last_active_date: NaiveDate,
29 pub total_time_seconds: u64,
31 #[serde(skip)]
33 pub session_start: DateTime<Utc>,
34 pub achievements: UserAchievements,
36 pub completed_challenges: HashSet<String>,
38}
39
40impl UserStats {
41 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 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 }
67 1 => {
68 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 self.current_streak = 1;
78 self.last_active_date = today;
79 }
80 }
81 }
82
83 pub fn record_lesson_completion(&mut self, lesson_id: String, difficulty: Difficulty) {
85 if self.lessons_completed.insert(lesson_id) {
86 *self.lessons_by_difficulty.entry(difficulty).or_insert(0) += 1;
88 }
89 }
90
91 pub fn record_command_use(&mut self, command: String) {
93 self.commands_used.insert(command);
94 self.total_commands_executed += 1;
95 }
96
97 pub fn complete_challenge(&mut self, challenge_id: String) {
99 self.completed_challenges.insert(challenge_id);
100 }
101
102 pub fn check_achievements(&mut self) -> Vec<Achievement> {
104 let mut newly_unlocked = Vec::new();
105
106 for achievement in all_achievements() {
107 if self.achievements.is_unlocked(&achievement.id) {
109 continue;
110 }
111
112 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 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 let completed = self.lessons_by_difficulty.get(difficulty).unwrap_or(&0);
136 *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 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 pub fn completion_percentage(&self, difficulty: Difficulty) -> f32 {
169 let completed = self.lessons_by_difficulty.get(&difficulty).unwrap_or(&0);
170 let total = 10.0;
173 (*completed as f32 / total) * 100.0
174 }
175
176 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 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 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 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 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 nearly_unlocked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
237 nearly_unlocked
238 }
239
240 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#[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 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()); 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 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); }
339
340 #[test]
341 fn test_time_tracking() {
342 let mut stats = UserStats::new();
343 stats.total_time_seconds = 7200 + 1800; 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}