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