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!("{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
105/// Get emoji for a habit status.
106pub 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
125/// Get emoji for a habit from its definition file.
126pub 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
136/// Get emoji for a weekday or month name.
137pub 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
150/// Get last week's habits data.
151///
152/// Returns habit name → {day_of_year → status} for the current week
153/// (Monday through Sunday). Includes habits from the `habits/` directory
154/// plus the default Mood habit.
155///
156/// Ported from Go `LastWeekHabits`.
157pub 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    // Walk back to Monday of the current week
164    let mut monday = now.date_naive();
165    while monday.weekday() != chrono::Weekday::Mon {
166        monday -= chrono::Duration::days(1);
167    }
168
169    // Collect existing habit names (from habits/ directory)
170    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    // Add default Mood habit (not in habits/ directory)
173    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
196/// Write habits data for a year back to the insights file.
197///
198/// Generates `insights/{year} Habits.md` with month-by-month habit status.
199/// Only months with at least one completed item are included.
200///
201/// Ported from Go `Write`.
202pub fn write_habits(fs: &VirtualFs, year: i32, habits: &Habits) -> Result<(), HabitsError> {
203    // Sort habit names alphabetically, Mood last
204    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        // Advance to the 1st of the next month
264        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
281/// Get the English month name for a month number (1–12).
282fn 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        // Create a habit file so it appears in the listing
348        fs.make_dir(DIR_HABITS).unwrap();
349        fs.write(DIR_HABITS, "Exercise.md", "\u{1F3CB}").unwrap();
350
351        // Write some habit data for the current year
352        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); // day 1 completed
357        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        // Should have exactly 7 entries per habit (Mon-Sun)
365        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        // No habits, no content (but file created)
378        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        // January 1 = day 1, mark as completed
388        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        // Should contain HABIT_COMPLETED for completed day
397        assert!(content.contains(HABIT_COMPLETED));
398    }
399
400    #[test]
401    fn test_write_habits_roundtrip() {
402        let (fs, _t) = test_fs();
403
404        // Create habit files
405        fs.make_dir(DIR_HABITS).unwrap();
406        fs.write(DIR_HABITS, "Exercise.md", "\u{1F3CB}").unwrap();
407
408        // Write habits data
409        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        // Read back using habits()
417        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}