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!("{year} Habits.md");
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!("{HABIT_SKIPPED}{HABIT_COMPLETED_AT_WEEKEND}{HABIT_COMPLETED}");
91 if !days.contains(marker.chars().next().unwrap().to_string().as_str()) {
92 continue;
93 }
94
95 let name = habit.trim();
96 let year_habits = habits.entry(name.to_string()).or_default();
97 for gr in days.graphemes(true) {
98 year_habits.insert(day_of_year, if gr != HABIT_SKIPPED { 1 } else { 0 });
99 day_of_year += 1;
100 }
101 }
102 Ok(habits)
103}
104
105pub fn emoji_for_status(
107 habit_name: &str,
108 day: &chrono::DateTime<chrono::FixedOffset>,
109 status: i32,
110) -> &'static str {
111 if habit_name == MOOD_HABIT {
112 return MOOD_EMOJIS.get(status as usize).unwrap_or(&HABIT_SKIPPED);
113 }
114 if status == 1 {
115 if day.weekday().num_days_from_sunday() >= 5 {
116 HABIT_COMPLETED_AT_WEEKEND
117 } else {
118 HABIT_COMPLETED
119 }
120 } else {
121 HABIT_SKIPPED
122 }
123}
124
125pub fn habit_emoji(fs: &VirtualFs, habit_name: &str) -> String {
127 if let Ok(content) = fs.read(DIR_HABITS, &format!("{habit_name}{MD_EXT}")) {
128 let trimmed = content.trim();
129 if !trimmed.is_empty() {
130 return trimmed.to_string();
131 }
132 }
133 weekday_emoji(habit_name).to_string()
134}
135
136pub fn weekday_emoji(key: &str) -> &'static str {
138 match key.to_lowercase().as_str() {
139 "monday" => "🌑",
140 "tuesday" => "🌒",
141 "wednesday" => "🌓",
142 "thursday" => "🌔",
143 "friday" => "🌕",
144 "saturday" => "🌝",
145 "sunday" => "🌛",
146 _ => "⚡️",
147 }
148}
149
150pub fn last_week_habits(fs: &VirtualFs, tz: chrono::FixedOffset) -> Result<Habits, HabitsError> {
158 let now = chrono::Utc::now().with_timezone(&tz);
159 let year = now.year();
160
161 let habits_for_year = habits(fs, year)?;
162
163 let mut monday = now.date_naive();
165 while monday.weekday() != chrono::Weekday::Mon {
166 monday -= chrono::Duration::days(1);
167 }
168
169 let existing = fs.files_and_dirs(DIR_HABITS)?;
171 let mut habit_names: Vec<String> = existing.iter().map(|e| e.display_name.clone()).collect();
172 if !habit_names.contains(&MOOD_HABIT.to_string()) {
174 habit_names.push(MOOD_HABIT.to_string());
175 }
176
177 let mut result: Habits = HashMap::new();
178 for name in &habit_names {
179 let mut week: YearHabits = HashMap::new();
180 for offset in 0..7i64 {
181 let day = monday + chrono::Duration::days(offset);
182 let year_day = day.ordinal() as i32;
183 let status = habits_for_year
184 .get(name)
185 .and_then(|y| y.get(&year_day))
186 .copied()
187 .unwrap_or(0);
188 week.insert(year_day, status);
189 }
190 result.insert(name.clone(), week);
191 }
192
193 Ok(result)
194}
195
196pub fn write_habits(fs: &VirtualFs, year: i32, habits: &Habits) -> Result<(), HabitsError> {
203 let mut habit_keys: Vec<String> = habits
205 .keys()
206 .filter(|k| *k != MOOD_HABIT)
207 .cloned()
208 .collect();
209 habit_keys.sort();
210 if habits.contains_key(MOOD_HABIT) {
211 habit_keys.push(MOOD_HABIT.to_string());
212 }
213
214 let mut content = String::new();
215 let mut day = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap();
216
217 while day.year() < year + 1 {
218 let mut habits_for_month = String::new();
219
220 for habit_name in &habit_keys {
221 let mut statuses = String::new();
222 let mut day_of_month = day;
223 let mut at_least_one_completion = false;
224
225 while day_of_month.month() == day.month() {
226 let year_day = day_of_month.ordinal() as i32;
227 let emoji = if let Some(status_map) = habits.get(habit_name) {
228 if let Some(&status) = status_map.get(&year_day) {
229 let dt = chrono::FixedOffset::east_opt(0)
230 .unwrap()
231 .from_utc_datetime(&day_of_month.and_hms_opt(12, 0, 0).unwrap());
232 let e = emoji_for_status(habit_name, &dt, status);
233 if e != HABIT_SKIPPED {
234 at_least_one_completion = true;
235 }
236 e
237 } else {
238 HABIT_SKIPPED
239 }
240 } else {
241 HABIT_SKIPPED
242 };
243 statuses.push_str(emoji);
244 day_of_month += chrono::Duration::days(1);
245 }
246
247 if at_least_one_completion {
248 habits_for_month.push_str(&format!("{statuses} {habit_name}\n"));
249 }
250 }
251
252 if !habits_for_month.is_empty() {
253 if !content.is_empty() {
254 content.push('\n');
255 }
256 content.push_str(&format!(
257 "### {}\n{}",
258 month_name(day.month()),
259 habits_for_month
260 ));
261 }
262
263 day = chrono::NaiveDate::from_ymd_opt(
265 if day.month() == 12 { year + 1 } else { year },
266 if day.month() == 12 {
267 1
268 } else {
269 day.month() + 1
270 },
271 1,
272 )
273 .unwrap();
274 }
275
276 let filename = format!("{year} Habits.md");
277 fs.write(DIR_INSIGHTS, &filename, &content)?;
278 Ok(())
279}
280
281fn month_name(month: u32) -> &'static str {
283 match month {
284 1 => "January",
285 2 => "February",
286 3 => "March",
287 4 => "April",
288 5 => "May",
289 6 => "June",
290 7 => "July",
291 8 => "August",
292 9 => "September",
293 10 => "October",
294 11 => "November",
295 12 => "December",
296 _ => "Unknown",
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use chrono::FixedOffset;
304 use chrono::TimeZone;
305 use tempfile::TempDir;
306
307 fn test_fs() -> (VirtualFs, TempDir) {
308 let dir = TempDir::new().unwrap();
309 let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
310 (fs, dir)
311 }
312
313 #[test]
314 fn test_emoji_for_status() {
315 let saturday = FixedOffset::east_opt(0)
316 .unwrap()
317 .with_ymd_and_hms(2024, 1, 6, 12, 0, 0)
318 .unwrap();
319 assert_eq!(
320 emoji_for_status("Exercise", &saturday, 1),
321 HABIT_COMPLETED_AT_WEEKEND
322 );
323 assert_eq!(emoji_for_status("Exercise", &saturday, 0), HABIT_SKIPPED);
324 }
325
326 #[test]
327 fn test_mood_emoji() {
328 let day = FixedOffset::east_opt(0)
329 .unwrap()
330 .with_ymd_and_hms(2024, 1, 1, 12, 0, 0)
331 .unwrap();
332 assert_eq!(emoji_for_status(MOOD_HABIT, &day, 0), HABIT_SKIPPED);
333 assert_eq!(emoji_for_status(MOOD_HABIT, &day, 5), "😊");
334 }
335
336 #[test]
337 fn test_weekday_emoji() {
338 assert_eq!(weekday_emoji("monday"), "🌑");
339 assert_eq!(weekday_emoji("unknown"), "⚡️");
340 }
341
342 #[test]
343 fn test_last_week_habits_basic() {
344 let (fs, _t) = test_fs();
345 let tz = FixedOffset::east_opt(0).unwrap();
346
347 fs.make_dir(DIR_HABITS).unwrap();
349 fs.write(DIR_HABITS, "Exercise.md", "\u{1F3CB}").unwrap();
350
351 let now = chrono::Utc::now().with_timezone(&tz);
353 let year = now.year();
354 let mut habits_data: Habits = HashMap::new();
355 let mut year_map: YearHabits = HashMap::new();
356 year_map.insert(1, 1); habits_data.insert("Exercise".to_string(), year_map);
358
359 write_habits(&fs, year, &habits_data).unwrap();
360
361 let result = last_week_habits(&fs, tz).unwrap();
362 assert!(result.contains_key("Exercise"));
363 assert!(result.contains_key(MOOD_HABIT));
364 assert_eq!(result.get("Exercise").unwrap().len(), 7);
366 }
367
368 #[test]
369 fn test_write_habits_empty() {
370 let (fs, _t) = test_fs();
371 let habits: Habits = HashMap::new();
372 write_habits(&fs, 2024, &habits).unwrap();
373
374 let filename = "2024 Habits.md";
375 assert!(fs.exists(DIR_INSIGHTS, filename).unwrap());
376 let content = fs.read(DIR_INSIGHTS, filename).unwrap();
377 assert_eq!(content, "");
379 }
380
381 #[test]
382 fn test_write_habits_with_data() {
383 let (fs, _t) = test_fs();
384
385 let mut habits: Habits = HashMap::new();
386 let mut year_map: YearHabits = HashMap::new();
387 year_map.insert(1, 1);
389 habits.insert("Exercise".to_string(), year_map);
390
391 write_habits(&fs, 2024, &habits).unwrap();
392
393 let content = fs.read(DIR_INSIGHTS, "2024 Habits.md").unwrap();
394 assert!(content.contains("### January"));
395 assert!(content.contains("Exercise"));
396 assert!(content.contains(HABIT_COMPLETED));
398 }
399
400 #[test]
401 fn test_write_habits_roundtrip() {
402 let (fs, _t) = test_fs();
403
404 fs.make_dir(DIR_HABITS).unwrap();
406 fs.write(DIR_HABITS, "Exercise.md", "\u{1F3CB}").unwrap();
407
408 let mut habits_data: Habits = HashMap::new();
410 let mut ym: YearHabits = HashMap::new();
411 ym.insert(1, 1);
412 habits_data.insert("Exercise".to_string(), ym);
413
414 write_habits(&fs, 2024, &habits_data).unwrap();
415
416 let read_back = habits(&fs, 2024).unwrap();
418 assert_eq!(read_back.get("Exercise").unwrap().get(&1), Some(&1));
419 }
420
421 #[test]
422 fn test_month_name() {
423 assert_eq!(month_name(1), "January");
424 assert_eq!(month_name(6), "June");
425 assert_eq!(month_name(12), "December");
426 }
427}