use crate::achievement::{Achievement, UnlockCondition, UserAchievements, all_achievements};
use crate::lesson::{Difficulty, LessonLibrary};
use chrono::{DateTime, Datelike, NaiveDate, Timelike, Utc, Weekday};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
fn builtin_difficulty_counts() -> &'static HashMap<Difficulty, usize> {
static COUNTS: OnceLock<HashMap<Difficulty, usize>> = OnceLock::new();
COUNTS.get_or_init(|| LessonLibrary::new().difficulty_counts())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserStats {
pub lessons_completed: HashSet<String>,
pub lessons_by_difficulty: HashMap<Difficulty, usize>,
pub commands_used: HashSet<String>,
pub total_commands_executed: usize,
pub current_streak: usize,
pub longest_streak: usize,
pub last_active_date: NaiveDate,
pub total_time_seconds: u64,
#[serde(skip)]
pub session_start: DateTime<Utc>,
pub achievements: UserAchievements,
pub completed_challenges: HashSet<String>,
}
impl UserStats {
pub fn new() -> Self {
Self {
lessons_completed: HashSet::new(),
lessons_by_difficulty: HashMap::new(),
commands_used: HashSet::new(),
total_commands_executed: 0,
current_streak: 0,
longest_streak: 0,
last_active_date: Utc::now().date_naive(),
total_time_seconds: 0,
session_start: Utc::now(),
achievements: UserAchievements::new(),
completed_challenges: HashSet::new(),
}
}
pub fn update_streak(&mut self) {
let today = Utc::now().date_naive();
let days_since_last = (today - self.last_active_date).num_days();
match days_since_last {
0 => {
}
1 => {
self.current_streak += 1;
self.last_active_date = today;
if self.current_streak > self.longest_streak {
self.longest_streak = self.current_streak;
}
}
_ => {
self.current_streak = 1;
self.last_active_date = today;
}
}
}
pub fn record_lesson_completion(&mut self, lesson_id: String, difficulty: Difficulty) {
if self.lessons_completed.insert(lesson_id) {
*self.lessons_by_difficulty.entry(difficulty).or_insert(0) += 1;
}
}
pub fn record_command_use(&mut self, command: String) {
self.commands_used.insert(command);
self.total_commands_executed += 1;
}
pub fn complete_challenge(&mut self, challenge_id: String) {
self.completed_challenges.insert(challenge_id);
}
pub fn record_event(&mut self, event_key: &str) -> Vec<Achievement> {
self.complete_challenge(event_key.to_string());
self.check_achievements()
}
pub fn check_achievements(&mut self) -> Vec<Achievement> {
self.check_achievements_with_counts(builtin_difficulty_counts())
}
pub fn check_achievements_with_library(&mut self, library: &LessonLibrary) -> Vec<Achievement> {
self.check_achievements_with_counts(&library.difficulty_counts())
}
fn check_achievements_with_counts(
&mut self,
difficulty_totals: &HashMap<Difficulty, usize>,
) -> Vec<Achievement> {
let mut newly_unlocked = Vec::new();
for achievement in all_achievements() {
if self.achievements.is_unlocked(&achievement.id) {
continue;
}
if self.check_unlock_condition(&achievement.condition, difficulty_totals) {
self.achievements.unlock(achievement.id.clone());
newly_unlocked.push(achievement);
}
}
newly_unlocked
}
fn check_unlock_condition(
&self,
condition: &UnlockCondition,
difficulty_totals: &HashMap<Difficulty, usize>,
) -> bool {
match condition {
UnlockCondition::CompleteLesson(lesson_id) => {
self.lessons_completed.contains(lesson_id)
}
UnlockCondition::CompleteLessons(count) => {
self.lessons_completed.len() >= *count
}
UnlockCondition::CompleteAllDifficulty(difficulty) => {
let completed = self.lessons_by_difficulty.get(difficulty).unwrap_or(&0);
match difficulty_totals.get(difficulty) {
Some(&total) if total > 0 => *completed >= total,
_ => false,
}
}
UnlockCondition::UseCommands(count) => {
self.commands_used.len() >= *count
}
UnlockCondition::MaintainStreak(days) => {
self.current_streak >= *days
}
UnlockCondition::CompleteChallenge(challenge_id) => {
self.completed_challenges.contains(challenge_id)
}
UnlockCondition::TimeOfDay { start, end } => {
let current_hour = Utc::now().hour() as u8;
if start < end {
current_hour >= *start && current_hour < *end
} else {
current_hour >= *start || current_hour < *end
}
}
UnlockCondition::IsWeekend => {
let weekday = Utc::now().weekday();
weekday == Weekday::Sat || weekday == Weekday::Sun
}
}
}
pub fn completion_percentage(&self, difficulty: Difficulty) -> f32 {
self.completion_percentage_with_counts(difficulty, builtin_difficulty_counts())
}
pub fn completion_percentage_with_library(
&self,
difficulty: Difficulty,
library: &LessonLibrary,
) -> f32 {
self.completion_percentage_with_counts(difficulty, &library.difficulty_counts())
}
fn completion_percentage_with_counts(
&self,
difficulty: Difficulty,
difficulty_totals: &HashMap<Difficulty, usize>,
) -> f32 {
let completed = self.lessons_by_difficulty.get(&difficulty).unwrap_or(&0);
let total = *difficulty_totals.get(&difficulty).unwrap_or(&0);
if total == 0 {
return 0.0;
}
(*completed as f32 / total as f32) * 100.0
}
pub fn overall_completion_percentage(&self, total_lessons: usize) -> f32 {
if total_lessons == 0 {
return 0.0;
}
(self.lessons_completed.len() as f32 / total_lessons as f32) * 100.0
}
pub fn update_session_time(&mut self) {
let now = Utc::now();
let session_duration = (now - self.session_start).num_seconds();
if session_duration > 0 {
self.total_time_seconds += session_duration as u64;
}
self.session_start = now;
}
pub fn formatted_time_spent(&self) -> String {
let hours = self.total_time_seconds / 3600;
let minutes = (self.total_time_seconds % 3600) / 60;
if hours > 0 {
format!("{}h {}m", hours, minutes)
} else {
format!("{}m", minutes)
}
}
pub fn progress_summary(&self) -> ProgressSummary {
ProgressSummary {
total_lessons_completed: self.lessons_completed.len(),
unique_commands_used: self.commands_used.len(),
total_commands_executed: self.total_commands_executed,
current_streak: self.current_streak,
longest_streak: self.longest_streak,
achievements_unlocked: self.achievements.total_unlocked(),
total_points: self.achievements.total_points(),
time_spent: self.formatted_time_spent(),
}
}
pub fn nearly_unlocked_achievements(&self) -> Vec<(Achievement, f32)> {
let mut nearly_unlocked = Vec::new();
for achievement in all_achievements() {
if self.achievements.is_unlocked(&achievement.id) {
continue;
}
let progress = self.achievement_progress(&achievement.condition);
if progress >= 0.5 && progress < 1.0 {
nearly_unlocked.push((achievement, progress));
}
}
nearly_unlocked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
nearly_unlocked
}
fn achievement_progress(&self, condition: &UnlockCondition) -> f32 {
match condition {
UnlockCondition::CompleteLessons(target) => {
(self.lessons_completed.len() as f32 / *target as f32).min(1.0)
}
UnlockCondition::UseCommands(target) => {
(self.commands_used.len() as f32 / *target as f32).min(1.0)
}
UnlockCondition::MaintainStreak(target) => {
(self.current_streak as f32 / *target as f32).min(1.0)
}
UnlockCondition::CompleteLesson(_) |
UnlockCondition::CompleteAllDifficulty(_) |
UnlockCondition::CompleteChallenge(_) |
UnlockCondition::TimeOfDay { .. } |
UnlockCondition::IsWeekend => {
if self.check_unlock_condition(condition, builtin_difficulty_counts()) {
1.0
} else {
0.0
}
}
}
}
}
impl Default for UserStats {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct ProgressSummary {
pub total_lessons_completed: usize,
pub unique_commands_used: usize,
pub total_commands_executed: usize,
pub current_streak: usize,
pub longest_streak: usize,
pub achievements_unlocked: usize,
pub total_points: u32,
pub time_spent: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_stats() {
let stats = UserStats::new();
assert_eq!(stats.lessons_completed.len(), 0);
assert_eq!(stats.current_streak, 0);
assert_eq!(stats.total_commands_executed, 0);
}
#[test]
fn test_record_lesson() {
let mut stats = UserStats::new();
stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
assert_eq!(stats.lessons_completed.len(), 1);
assert_eq!(*stats.lessons_by_difficulty.get(&Difficulty::Beginner).unwrap(), 1);
stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
assert_eq!(stats.lessons_completed.len(), 1);
}
#[test]
fn test_record_command() {
let mut stats = UserStats::new();
stats.record_command_use("ls".to_string());
stats.record_command_use("cd".to_string());
stats.record_command_use("ls".to_string());
assert_eq!(stats.commands_used.len(), 2);
assert_eq!(stats.total_commands_executed, 3);
}
#[test]
fn test_check_achievements() {
let mut stats = UserStats::new();
stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
let unlocked = stats.check_achievements();
assert!(!unlocked.is_empty());
assert!(unlocked.iter().any(|a| a.id == "first_steps"));
}
#[test]
fn test_completion_percentage() {
let mut stats = UserStats::new();
stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
stats.record_lesson_completion("lesson2".to_string(), Difficulty::Beginner);
let total_beginner = LessonLibrary::new()
.by_difficulty(Difficulty::Beginner)
.len();
let expected = (2.0 / total_beginner as f32) * 100.0;
let percentage = stats.completion_percentage(Difficulty::Beginner);
assert_eq!(percentage, expected);
assert_eq!(stats.completion_percentage(Difficulty::Expert), 0.0);
}
#[test]
fn test_complete_all_difficulty_uses_real_lesson_counts() {
let library = LessonLibrary::new();
let beginner_lessons: Vec<String> = library
.by_difficulty(Difficulty::Beginner)
.iter()
.map(|l| l.id.clone())
.collect();
assert!(!beginner_lessons.is_empty());
let mut stats = UserStats::new();
for id in &beginner_lessons[..beginner_lessons.len() - 1] {
stats.record_lesson_completion(id.clone(), Difficulty::Beginner);
}
stats.check_achievements_with_library(&library);
assert!(!stats.achievements.is_unlocked("beginner_complete"));
stats.record_lesson_completion(
beginner_lessons.last().unwrap().clone(),
Difficulty::Beginner,
);
stats.check_achievements_with_library(&library);
assert!(stats.achievements.is_unlocked("beginner_complete"));
assert!(!stats.achievements.is_unlocked("advanced_complete"));
assert!(!stats.achievements.is_unlocked("expert_complete"));
}
#[test]
fn test_navigation_master_matches_real_lesson_id() {
let mut stats = UserStats::new();
stats.record_lesson_completion("nav-basics".to_string(), Difficulty::Beginner);
stats.check_achievements();
assert!(stats.achievements.is_unlocked("navigation_master"));
}
#[test]
fn test_complete_lesson_achievement_ids_exist_in_library() {
let library = LessonLibrary::new();
for achievement in all_achievements() {
if let UnlockCondition::CompleteLesson(lesson_id) = &achievement.condition {
assert!(
library.get(lesson_id).is_some(),
"Achievement '{}' references unknown lesson id '{}'",
achievement.id,
lesson_id
);
}
}
}
#[test]
fn test_record_event_unlocks_exploration_achievements() {
let mut stats = UserStats::new();
let unlocked = stats.record_event("use_ai");
assert!(unlocked.iter().any(|a| a.id == "ai_assistant"));
stats.record_event("change_theme");
assert!(stats.achievements.is_unlocked("theme_explorer"));
stats.record_event("visit_all_panels");
assert!(stats.achievements.is_unlocked("panel_master"));
stats.record_event("use_context");
assert!(stats.achievements.is_unlocked("context_aware"));
stats.record_event("speed_lesson");
assert!(stats.achievements.is_unlocked("speed_learner"));
stats.record_event("perfect_lesson");
assert!(stats.achievements.is_unlocked("perfect_lesson"));
stats.record_event("first_challenge");
assert!(stats.achievements.is_unlocked("challenge_accepted"));
let again = stats.record_event("use_ai");
assert!(again.is_empty());
}
#[test]
fn test_time_tracking() {
let mut stats = UserStats::new();
stats.total_time_seconds = 7200 + 1800;
let formatted = stats.formatted_time_spent();
assert_eq!(formatted, "2h 30m");
}
#[test]
fn test_progress_summary() {
let mut stats = UserStats::new();
stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
stats.record_command_use("ls".to_string());
let summary = stats.progress_summary();
assert_eq!(summary.total_lessons_completed, 1);
assert_eq!(summary.unique_commands_used, 1);
}
#[test]
fn test_challenge_completion() {
let mut stats = UserStats::new();
stats.complete_challenge("use_ai".to_string());
assert!(stats.completed_challenges.contains("use_ai"));
}
}