Skip to main content

oxios_markdown/
habits.rs

1//! Habit tracking.
2//!
3//! Ported from files.md (`server/habits/mod.rs`) by Artem Zakirullin.
4//! Reads/writes habit data from the insights directory.
5
6use 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/// Habits-specific errors.
20#[derive(Debug, thiserror::Error)]
21pub enum HabitsError {
22    /// Malformed month line in habits file.
23    #[error("malformed month line")]
24    MalformedMonthLine,
25    /// Other error.
26    #[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
36/// Read habits for a given year.
37pub 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
108/// Get emoji for a habit status.
109pub 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
128/// Get emoji for a habit from its definition file.
129pub 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
139/// Get emoji for a weekday or month name.
140pub 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
153/// Get last week's habits data.
154///
155/// Returns habit name → {day_of_year → status} for the current week
156/// (Monday through Sunday). Includes habits from the `habits/` directory
157/// plus the default Mood habit.
158///
159/// Ported from Go `LastWeekHabits`.
160pub 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    // Walk back to Monday of the current week
167    let mut monday = now.date_naive();
168    while monday.weekday() != chrono::Weekday::Mon {
169        monday -= chrono::Duration::days(1);
170    }
171
172    // Collect existing habit names (from habits/ directory)
173    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    // Add default Mood habit (not in habits/ directory)
176    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
199/// Write habits data for a year back to the insights file.
200///
201/// Generates `insights/{year} Habits.md` with month-by-month habit status.
202/// Only months with at least one completed item are included.
203///
204/// Ported from Go `Write`.
205pub fn write_habits(fs: &VirtualFs, year: i32, habits: &Habits) -> Result<(), HabitsError> {
206    // Sort habit names alphabetically, Mood last
207    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        // Advance to the 1st of the next month
267        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
284/// Get the English month name for a month number (1–12).
285fn 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        // Create a habit file so it appears in the listing
351        fs.make_dir(DIR_HABITS).unwrap();
352        fs.write(DIR_HABITS, "Exercise.md", "\u{1F3CB}").unwrap();
353
354        // Write some habit data for the current year
355        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); // day 1 completed
360        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        // Should have exactly 7 entries per habit (Mon-Sun)
368        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        // No habits, no content (but file created)
381        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        // January 1 = day 1, mark as completed
391        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        // Should contain HABIT_COMPLETED for completed day
400        assert!(content.contains(HABIT_COMPLETED));
401    }
402
403    #[test]
404    fn test_write_habits_roundtrip() {
405        let (fs, _t) = test_fs();
406
407        // Create habit files
408        fs.make_dir(DIR_HABITS).unwrap();
409        fs.write(DIR_HABITS, "Exercise.md", "\u{1F3CB}").unwrap();
410
411        // Write habits data
412        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        // Read back using habits()
420        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}