1use std::collections::HashMap;
7use std::str::FromStr;
8
9use chrono::{Datelike, TimeZone};
10use unicode_segmentation::UnicodeSegmentation;
11
12use crate::fs::VirtualFs;
13use crate::parser::norm_new_lines;
14use crate::types::{
15 FsError, Habits, YearHabits, DIR_HABITS, DIR_INSIGHTS, HABIT_COMPLETED,
16 HABIT_COMPLETED_AT_WEEKEND, HABIT_SKIPPED, MD_EXT, MOOD_EMOJIS, MOOD_HABIT,
17};
18
19#[derive(Debug, thiserror::Error)]
21pub enum HabitsError {
22 #[error("malformed month line")]
24 MalformedMonthLine,
25 #[error("{0}")]
27 Other(String),
28}
29
30impl From<FsError> for HabitsError {
31 fn from(e: FsError) -> Self {
32 HabitsError::Other(e.to_string())
33 }
34}
35
36pub fn habits(fs: &VirtualFs, year: i32) -> Result<Habits, HabitsError> {
38 let existing = fs.files_and_dirs(DIR_HABITS)?;
39 let mut habits: Habits = HashMap::new();
40 for entry in &existing {
41 habits.insert(entry.display_name.clone(), HashMap::new());
42 }
43
44 let filename = format!("{} Habits.md", year);
45 if !fs.exists(DIR_INSIGHTS, &filename)? {
46 return Ok(habits);
47 }
48
49 let content = fs.read(DIR_INSIGHTS, &filename)?;
50 let normalized = norm_new_lines(&content);
51 let mut month = chrono::Month::January;
52
53 for line in normalized.split('\n') {
54 let line = line.trim();
55 if line.is_empty() {
56 continue;
57 }
58
59 if line.starts_with("###") {
60 let parts: Vec<&str> = line.split(' ').collect();
61 if parts.len() >= 2 {
62 if let Ok(m) = chrono::Month::from_str(parts[1]) {
63 month = m;
64 }
65 }
66 continue;
67 }
68
69 let parts: Vec<&str> = line.splitn(2, ' ').collect();
70 if parts.len() < 2 {
71 continue;
72 }
73
74 let days = parts[0];
75 let habit = parts[1];
76 let first_day =
77 chrono::NaiveDate::from_ymd_opt(year, month.number_from_month(), 1).unwrap();
78 let mut day_of_year = first_day.ordinal() as i32;
79
80 if habit.contains(MOOD_HABIT) {
81 let moods = habits.entry(MOOD_HABIT.to_string()).or_default();
82 for gr in days.graphemes(true) {
83 let power = MOOD_EMOJIS.iter().position(|&e| e == gr).unwrap_or(0) as i32;
84 moods.insert(day_of_year, power);
85 day_of_year += 1;
86 }
87 continue;
88 }
89
90 let marker = format!(
91 "{}{}{}",
92 HABIT_SKIPPED, HABIT_COMPLETED_AT_WEEKEND, HABIT_COMPLETED
93 );
94 if !days.contains(marker.chars().next().unwrap().to_string().as_str()) {
95 continue;
96 }
97
98 let name = habit.trim();
99 let year_habits = habits.entry(name.to_string()).or_default();
100 for gr in days.graphemes(true) {
101 year_habits.insert(day_of_year, if gr != HABIT_SKIPPED { 1 } else { 0 });
102 day_of_year += 1;
103 }
104 }
105 Ok(habits)
106}
107
108pub fn emoji_for_status(
110 habit_name: &str,
111 day: &chrono::DateTime<chrono::FixedOffset>,
112 status: i32,
113) -> &'static str {
114 if habit_name == MOOD_HABIT {
115 return MOOD_EMOJIS.get(status as usize).unwrap_or(&HABIT_SKIPPED);
116 }
117 if status == 1 {
118 if day.weekday().num_days_from_sunday() >= 5 {
119 HABIT_COMPLETED_AT_WEEKEND
120 } else {
121 HABIT_COMPLETED
122 }
123 } else {
124 HABIT_SKIPPED
125 }
126}
127
128pub fn habit_emoji(fs: &VirtualFs, habit_name: &str) -> String {
130 if let Ok(content) = fs.read(DIR_HABITS, &format!("{}{}", habit_name, MD_EXT)) {
131 let trimmed = content.trim();
132 if !trimmed.is_empty() {
133 return trimmed.to_string();
134 }
135 }
136 weekday_emoji(habit_name).to_string()
137}
138
139pub fn weekday_emoji(key: &str) -> &'static str {
141 match key.to_lowercase().as_str() {
142 "monday" => "🌑",
143 "tuesday" => "🌒",
144 "wednesday" => "🌓",
145 "thursday" => "🌔",
146 "friday" => "🌕",
147 "saturday" => "🌝",
148 "sunday" => "🌛",
149 _ => "⚡️",
150 }
151}
152
153pub fn last_week_habits(fs: &VirtualFs, tz: chrono::FixedOffset) -> Result<Habits, HabitsError> {
161 let now = chrono::Utc::now().with_timezone(&tz);
162 let year = now.year();
163
164 let habits_for_year = habits(fs, year)?;
165
166 let mut monday = now.date_naive();
168 while monday.weekday() != chrono::Weekday::Mon {
169 monday -= chrono::Duration::days(1);
170 }
171
172 let existing = fs.files_and_dirs(DIR_HABITS)?;
174 let mut habit_names: Vec<String> = existing.iter().map(|e| e.display_name.clone()).collect();
175 if !habit_names.contains(&MOOD_HABIT.to_string()) {
177 habit_names.push(MOOD_HABIT.to_string());
178 }
179
180 let mut result: Habits = HashMap::new();
181 for name in &habit_names {
182 let mut week: YearHabits = HashMap::new();
183 for offset in 0..7i64 {
184 let day = monday + chrono::Duration::days(offset);
185 let year_day = day.ordinal() as i32;
186 let status = habits_for_year
187 .get(name)
188 .and_then(|y| y.get(&year_day))
189 .copied()
190 .unwrap_or(0);
191 week.insert(year_day, status);
192 }
193 result.insert(name.clone(), week);
194 }
195
196 Ok(result)
197}
198
199pub fn write_habits(fs: &VirtualFs, year: i32, habits: &Habits) -> Result<(), HabitsError> {
206 let mut habit_keys: Vec<String> = habits
208 .keys()
209 .filter(|k| *k != MOOD_HABIT)
210 .cloned()
211 .collect();
212 habit_keys.sort();
213 if habits.contains_key(MOOD_HABIT) {
214 habit_keys.push(MOOD_HABIT.to_string());
215 }
216
217 let mut content = String::new();
218 let mut day = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap();
219
220 while day.year() < year + 1 {
221 let mut habits_for_month = String::new();
222
223 for habit_name in &habit_keys {
224 let mut statuses = String::new();
225 let mut day_of_month = day;
226 let mut at_least_one_completion = false;
227
228 while day_of_month.month() == day.month() {
229 let year_day = day_of_month.ordinal() as i32;
230 let emoji = if let Some(status_map) = habits.get(habit_name) {
231 if let Some(&status) = status_map.get(&year_day) {
232 let dt = chrono::FixedOffset::east_opt(0)
233 .unwrap()
234 .from_utc_datetime(&day_of_month.and_hms_opt(12, 0, 0).unwrap());
235 let e = emoji_for_status(habit_name, &dt, status);
236 if e != HABIT_SKIPPED {
237 at_least_one_completion = true;
238 }
239 e
240 } else {
241 HABIT_SKIPPED
242 }
243 } else {
244 HABIT_SKIPPED
245 };
246 statuses.push_str(emoji);
247 day_of_month += chrono::Duration::days(1);
248 }
249
250 if at_least_one_completion {
251 habits_for_month.push_str(&format!("{} {}\n", statuses, habit_name));
252 }
253 }
254
255 if !habits_for_month.is_empty() {
256 if !content.is_empty() {
257 content.push('\n');
258 }
259 content.push_str(&format!(
260 "### {}\n{}",
261 month_name(day.month()),
262 habits_for_month
263 ));
264 }
265
266 day = chrono::NaiveDate::from_ymd_opt(
268 if day.month() == 12 { year + 1 } else { year },
269 if day.month() == 12 {
270 1
271 } else {
272 day.month() + 1
273 },
274 1,
275 )
276 .unwrap();
277 }
278
279 let filename = format!("{} Habits.md", year);
280 fs.write(DIR_INSIGHTS, &filename, &content)?;
281 Ok(())
282}
283
284fn month_name(month: u32) -> &'static str {
286 match month {
287 1 => "January",
288 2 => "February",
289 3 => "March",
290 4 => "April",
291 5 => "May",
292 6 => "June",
293 7 => "July",
294 8 => "August",
295 9 => "September",
296 10 => "October",
297 11 => "November",
298 12 => "December",
299 _ => "Unknown",
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use chrono::FixedOffset;
307 use chrono::TimeZone;
308 use tempfile::TempDir;
309
310 fn test_fs() -> (VirtualFs, TempDir) {
311 let dir = TempDir::new().unwrap();
312 let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
313 (fs, dir)
314 }
315
316 #[test]
317 fn test_emoji_for_status() {
318 let saturday = FixedOffset::east_opt(0)
319 .unwrap()
320 .with_ymd_and_hms(2024, 1, 6, 12, 0, 0)
321 .unwrap();
322 assert_eq!(
323 emoji_for_status("Exercise", &saturday, 1),
324 HABIT_COMPLETED_AT_WEEKEND
325 );
326 assert_eq!(emoji_for_status("Exercise", &saturday, 0), HABIT_SKIPPED);
327 }
328
329 #[test]
330 fn test_mood_emoji() {
331 let day = FixedOffset::east_opt(0)
332 .unwrap()
333 .with_ymd_and_hms(2024, 1, 1, 12, 0, 0)
334 .unwrap();
335 assert_eq!(emoji_for_status(MOOD_HABIT, &day, 0), HABIT_SKIPPED);
336 assert_eq!(emoji_for_status(MOOD_HABIT, &day, 5), "😊");
337 }
338
339 #[test]
340 fn test_weekday_emoji() {
341 assert_eq!(weekday_emoji("monday"), "🌑");
342 assert_eq!(weekday_emoji("unknown"), "⚡️");
343 }
344
345 #[test]
346 fn test_last_week_habits_basic() {
347 let (fs, _t) = test_fs();
348 let tz = FixedOffset::east_opt(0).unwrap();
349
350 fs.make_dir(DIR_HABITS).unwrap();
352 fs.write(DIR_HABITS, "Exercise.md", "\u{1F3CB}").unwrap();
353
354 let now = chrono::Utc::now().with_timezone(&tz);
356 let year = now.year();
357 let mut habits_data: Habits = HashMap::new();
358 let mut year_map: YearHabits = HashMap::new();
359 year_map.insert(1, 1); habits_data.insert("Exercise".to_string(), year_map);
361
362 write_habits(&fs, year, &habits_data).unwrap();
363
364 let result = last_week_habits(&fs, tz).unwrap();
365 assert!(result.contains_key("Exercise"));
366 assert!(result.contains_key(MOOD_HABIT));
367 assert_eq!(result.get("Exercise").unwrap().len(), 7);
369 }
370
371 #[test]
372 fn test_write_habits_empty() {
373 let (fs, _t) = test_fs();
374 let habits: Habits = HashMap::new();
375 write_habits(&fs, 2024, &habits).unwrap();
376
377 let filename = "2024 Habits.md";
378 assert!(fs.exists(DIR_INSIGHTS, filename).unwrap());
379 let content = fs.read(DIR_INSIGHTS, filename).unwrap();
380 assert_eq!(content, "");
382 }
383
384 #[test]
385 fn test_write_habits_with_data() {
386 let (fs, _t) = test_fs();
387
388 let mut habits: Habits = HashMap::new();
389 let mut year_map: YearHabits = HashMap::new();
390 year_map.insert(1, 1);
392 habits.insert("Exercise".to_string(), year_map);
393
394 write_habits(&fs, 2024, &habits).unwrap();
395
396 let content = fs.read(DIR_INSIGHTS, "2024 Habits.md").unwrap();
397 assert!(content.contains("### January"));
398 assert!(content.contains("Exercise"));
399 assert!(content.contains(HABIT_COMPLETED));
401 }
402
403 #[test]
404 fn test_write_habits_roundtrip() {
405 let (fs, _t) = test_fs();
406
407 fs.make_dir(DIR_HABITS).unwrap();
409 fs.write(DIR_HABITS, "Exercise.md", "\u{1F3CB}").unwrap();
410
411 let mut habits_data: Habits = HashMap::new();
413 let mut ym: YearHabits = HashMap::new();
414 ym.insert(1, 1);
415 habits_data.insert("Exercise".to_string(), ym);
416
417 write_habits(&fs, 2024, &habits_data).unwrap();
418
419 let read_back = habits(&fs, 2024).unwrap();
421 assert_eq!(read_back.get("Exercise").unwrap().get(&1), Some(&1));
422 }
423
424 #[test]
425 fn test_month_name() {
426 assert_eq!(month_name(1), "January");
427 assert_eq!(month_name(6), "June");
428 assert_eq!(month_name(12), "December");
429 }
430}