1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3
4#[derive(Debug, Clone, Serialize, Deserialize, Default)]
5pub struct UserProgress {
6 pub xp: u64,
7 pub level: u32,
8 pub streak_days: u32,
9 pub last_session: Option<String>,
10 pub exercises: HashMap<String, ExerciseProgress>,
11 pub modules: HashMap<String, ModuleProgress>,
12 #[serde(default)]
13 pub combo: u32,
14 #[serde(default)]
15 pub best_combo: u32,
16 #[serde(default)]
17 pub streak_freezes: u8,
18 #[serde(default)]
19 pub last_streak_date: Option<String>,
20 #[serde(default)]
21 pub achievements: HashSet<String>,
22 #[serde(default)]
23 pub title: Option<String>,
24 #[serde(default)]
25 pub last_weekly_freeze_date: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ExerciseProgress {
30 pub exercise_id: String,
31 pub attempts: u32,
32 pub correct_count: u32,
33 pub last_attempt: Option<String>,
34 pub srs: SrsData,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SrsData {
39 pub ease_factor: f64,
40 pub interval: u32,
41 pub repetitions: u32,
42 pub next_review: Option<String>,
43}
44
45impl Default for SrsData {
46 fn default() -> Self {
47 Self {
48 ease_factor: 2.5,
49 interval: 1,
50 repetitions: 0,
51 next_review: None,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ModuleProgress {
58 pub module_id: String,
59 pub unlocked: bool,
60 pub completed: bool,
61 pub stars: u8,
62 pub best_score: u32,
63 pub attempts: u32,
64}
65
66impl Default for ModuleProgress {
67 fn default() -> Self {
68 Self {
69 module_id: String::new(),
70 unlocked: false,
71 completed: false,
72 stars: 0,
73 best_score: 0,
74 attempts: 0,
75 }
76 }
77}
78
79impl UserProgress {
80 pub fn new() -> Self {
81 Self {
82 level: 1,
83 ..Default::default()
84 }
85 }
86
87 pub fn load() -> Self {
88 #[cfg(target_arch = "wasm32")]
89 {
90 crate::storage::load_raw()
91 .and_then(|json| serde_json::from_str(&json).ok())
92 .unwrap_or_else(Self::new)
93 }
94 #[cfg(not(target_arch = "wasm32"))]
95 {
96 Self::new()
97 }
98 }
99
100 pub fn save(&self) {
101 #[cfg(target_arch = "wasm32")]
102 {
103 if let Ok(json) = serde_json::to_string(self) {
104 crate::storage::save_raw(&json);
105 }
106 }
107 }
108
109 pub fn add_xp(&mut self, amount: u64) {
110 self.xp += amount;
111 self.level = calculate_level(self.xp);
112 self.save();
113 }
114
115 pub fn record_attempt(&mut self, exercise_id: &str, correct: bool) {
116 let entry = self.exercises.entry(exercise_id.to_string()).or_insert_with(|| {
117 ExerciseProgress {
118 exercise_id: exercise_id.to_string(),
119 attempts: 0,
120 correct_count: 0,
121 last_attempt: None,
122 srs: SrsData::default(),
123 }
124 });
125
126 entry.attempts += 1;
127 if correct {
128 entry.correct_count += 1;
129 }
130
131 self.save();
132 }
133
134 pub fn get_exercise_progress(&self, exercise_id: &str) -> Option<&ExerciseProgress> {
135 self.exercises.get(exercise_id)
136 }
137
138 pub fn get_module_progress(&self, module_id: &str) -> Option<&ModuleProgress> {
139 self.modules.get(module_id)
140 }
141
142 pub fn update_module_score(&mut self, module_id: &str, score: u32) {
143 let entry = self.modules.entry(module_id.to_string()).or_insert_with(|| {
144 ModuleProgress {
145 module_id: module_id.to_string(),
146 ..Default::default()
147 }
148 });
149
150 entry.attempts += 1;
151 if score > entry.best_score {
152 entry.best_score = score;
153 }
154
155 self.save();
156 }
157}
158
159pub fn calculate_level(xp: u64) -> u32 {
160 ((xp as f64).sqrt() / 10.0).floor() as u32 + 1
161}
162
163pub fn xp_for_level(level: u32) -> u64 {
164 let l = level as u64;
165 l * l * 100
166}
167
168pub fn calculate_xp_reward(difficulty: u32, first_try: bool, streak_days: u32) -> u64 {
169 let base: u64 = 10;
170 let difficulty_bonus = (difficulty.saturating_sub(1) as u64) * 5;
171 let first_try_bonus = if first_try { 5 } else { 0 };
172 let streak_bonus = (streak_days.min(7) as u64) * 2;
173
174 base + difficulty_bonus + first_try_bonus + streak_bonus
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn test_level_calculation() {
183 assert_eq!(calculate_level(0), 1);
184 assert_eq!(calculate_level(100), 2);
185 assert_eq!(calculate_level(400), 3);
186 assert_eq!(calculate_level(900), 4);
187 }
188
189 #[test]
190 fn test_xp_reward() {
191 assert_eq!(calculate_xp_reward(1, false, 0), 10);
192 assert_eq!(calculate_xp_reward(1, true, 0), 15);
193 assert_eq!(calculate_xp_reward(2, false, 0), 15);
194 assert_eq!(calculate_xp_reward(1, false, 3), 16);
195 assert_eq!(calculate_xp_reward(3, true, 5), 10 + 10 + 5 + 10);
196 }
197
198 #[test]
199 fn test_user_progress_record() {
200 let mut progress = UserProgress::new();
201 progress.record_attempt("test_q1", true);
202 progress.record_attempt("test_q1", false);
203
204 let ex = progress.get_exercise_progress("test_q1").unwrap();
205 assert_eq!(ex.attempts, 2);
206 assert_eq!(ex.correct_count, 1);
207 }
208}