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    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/// 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                && 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
112/// Get emoji for a habit status.
113pub 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
132/// Get emoji for a habit from its definition file.
133pub 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
143/// Get emoji for a weekday or month name.
144pub 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
157/// Get last week's habits data.
158///
159/// Returns habit name → {day_of_year → status} for the current week
160/// (Monday through Sunday). Includes habits from the `habits/` directory
161/// plus the default Mood habit.
162///
163/// Ported from Go `LastWeekHabits`.
164pub 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    // Walk back to Monday of the current week
171    let mut monday = now.date_naive();
172    while monday.weekday() != chrono::Weekday::Mon {
173        monday -= chrono::Duration::days(1);
174    }
175
176    // Collect existing habit names (from habits/ directory)
177    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    // Add default Mood habit (not in habits/ directory)
180    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
203/// Write habits data for a year back to the insights file.
204///
205/// Generates `insights/{year} Habits.md` with month-by-month habit status.
206/// Only months with at least one completed item are included.
207///
208/// Ported from Go `Write`.
209pub fn write_habits(fs: &VirtualFs, year: i32, habits: &Habits) -> Result<(), HabitsError> {
210    // Sort habit names alphabetically, Mood last
211    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        // Advance to the 1st of the next month
275        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
292/// Get the English month name for a month number (1–12).
293fn 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        // Create a habit file so it appears in the listing
359        fs.make_dir(DIR_HABITS).unwrap();
360        fs.write(DIR_HABITS, "Exercise.md", "\u{1F3CB}").unwrap();
361
362        // Write some habit data for the current year
363        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); // day 1 completed
368        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        // Should have exactly 7 entries per habit (Mon-Sun)
376        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        // No habits, no content (but file created)
389        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        // January 1 = day 1, mark as completed
399        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        // Should contain HABIT_COMPLETED for completed day
408        assert!(content.contains(HABIT_COMPLETED));
409    }
410
411    #[test]
412    fn test_write_habits_roundtrip() {
413        let (fs, _t) = test_fs();
414
415        // Create habit files
416        fs.make_dir(DIR_HABITS).unwrap();
417        fs.write(DIR_HABITS, "Exercise.md", "\u{1F3CB}").unwrap();
418
419        // Write habits data
420        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        // Read back using habits()
428        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}