Skip to content

Commit a9a3cb0

Browse files
committed
feat: add stats view mode with streaks and completion rate
Add a fifth view mode, Stats, to the `v` cycle (day -> week -> month -> year -> stats). For each habit it shows the current streak, longest streak, total completions, and a completion rate over the span since the first reached day. The current streak tolerates a still-open today by counting back from yesterday when today is not yet done. Streaks fold in archived months via the preloaded archived_reached set, so they span data that has been archived out of the live record, not just the current file. Also document the new mode in the keys help text.
1 parent 368aeb8 commit a9a3cb0

4 files changed

Lines changed: 84 additions & 2 deletions

File tree

src/app/impl_self.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ impl App {
367367
"w" | "write" => "write current state to disk (alias: w)",
368368
"h"|"?" | "help" => "help [<command>|commands|keys] (aliases: h, ?)",
369369
"cmds" | "commands" => "add, delete, month-{prev,next}, archive, help, quit",
370-
"keys" => "hjkl: move | HJKL: cursor | n/Enter: +1 | p/BS: -1 | v: cycle view (day/week/month/year) | V: all week | []: month | Esc: reset",
370+
"keys" => "hjkl: move | HJKL: cursor | n/Enter: +1 | p/BS: -1 | v: cycle view (day/week/month/year/stats) | []: month | Esc: reset",
371371
"wq" => "write current state to disk and quit dijo",
372372
_ => "unknown command or help topic.",
373373
}

src/app/impl_view.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ impl View for App {
9898
ViewMode::Day => ViewMode::Week,
9999
ViewMode::Week => ViewMode::Month,
100100
ViewMode::Month => ViewMode::Year,
101-
ViewMode::Year => ViewMode::Day,
101+
ViewMode::Year => ViewMode::Stats,
102+
ViewMode::Stats => ViewMode::Day,
102103
};
103104
for habit in self.habits.iter_mut() {
104105
habit.inner_data_mut_ref().set_view_mode(next);

src/habit/prelude.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub enum ViewMode {
1414
Week,
1515
Month,
1616
Year,
17+
Stats,
1718
}
1819

1920
impl fmt::Display for ViewMode {
@@ -23,6 +24,7 @@ impl fmt::Display for ViewMode {
2324
ViewMode::Week => write!(f, "WEEK"),
2425
ViewMode::Month => write!(f, "MONTH"),
2526
ViewMode::Year => write!(f, "YEAR"),
27+
ViewMode::Stats => write!(f, "STATS"),
2628
}
2729
}
2830
}

src/views.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use cursive::theme::{ColorStyle, Effect, Style};
44
use cursive::view::{CannotFocus, View};
55
use cursive::{Printer, Vec2};
66

7+
use std::collections::HashSet;
8+
79
use chrono::prelude::*;
810
use chrono::{Local, NaiveDate};
911

@@ -232,11 +234,88 @@ where
232234
}
233235
};
234236

237+
let draw_stats = |printer: &Printer| {
238+
let today = Local::now().date_naive();
239+
240+
// every day this habit reached its goal, including months that
241+
// have since been archived out of the live record
242+
let mut reached: Vec<NaiveDate> = self
243+
.get_dates()
244+
.into_iter()
245+
.filter(|&d| self.reached_goal(d))
246+
.chain(archived.iter().copied())
247+
.collect();
248+
reached.sort_unstable();
249+
reached.dedup();
250+
251+
let total = reached.len() as u32;
252+
let reached_set: HashSet<NaiveDate> = reached.iter().copied().collect();
253+
254+
// longest run of consecutive reached days
255+
let mut longest = 0u32;
256+
let mut run = 0u32;
257+
let mut prev: Option<NaiveDate> = None;
258+
for &d in &reached {
259+
run = match prev {
260+
Some(p) if p.succ_opt() == Some(d) => run + 1,
261+
_ => 1,
262+
};
263+
longest = longest.max(run);
264+
prev = Some(d);
265+
}
266+
267+
// current streak: count back from today, tolerating a still-open
268+
// today (start from yesterday if today isn't done yet)
269+
let mut current = 0u32;
270+
let mut day = if reached_set.contains(&today) {
271+
Some(today)
272+
} else {
273+
today.pred_opt()
274+
};
275+
while let Some(d) = day {
276+
if reached_set.contains(&d) {
277+
current += 1;
278+
day = d.pred_opt();
279+
} else {
280+
break;
281+
}
282+
}
283+
284+
// completion rate over the span since the first reached day
285+
let rate = match reached.first() {
286+
Some(&first) => {
287+
let span = (today - first).num_days() + 1;
288+
if span > 0 {
289+
(total as i64 * 100 / span) as u32
290+
} else {
291+
0
292+
}
293+
}
294+
None => 0,
295+
};
296+
297+
let lines = [
298+
format!("Current {current:>4} days"),
299+
format!("Longest {longest:>4} days"),
300+
format!("Done {total:>4} time{}", if total == 1 { "" } else { "s" }),
301+
format!("Rate {rate:>4} %"),
302+
];
303+
for (i, line) in lines.iter().enumerate() {
304+
let style = if i == 0 && current > 0 {
305+
goal_reached_style
306+
} else {
307+
Style::none()
308+
};
309+
printer.with_style(style, |p| p.print((2, i + 2), line));
310+
}
311+
};
312+
235313
match self.inner_data_ref().view_mode() {
236314
ViewMode::Day => draw_day(printer),
237315
ViewMode::Week => draw_week(printer),
238316
ViewMode::Month => draw_month(printer),
239317
ViewMode::Year => draw_year(printer),
318+
ViewMode::Stats => draw_stats(printer),
240319
};
241320
}
242321

0 commit comments

Comments
 (0)