use std::collections::BTreeMap;
use chrono::{Duration, NaiveDate};
use doing_taskpaper::Entry;
use doing_time::{DurationFormat, FormattedDuration, format_tag_total};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum TagSortField {
#[default]
Name,
Time,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum TagSortOrder {
#[default]
Asc,
Desc,
}
#[derive(Clone, Debug, Default)]
pub struct TotalsOptions {
pub duration_format: Option<DurationFormat>,
pub enabled: bool,
pub groupings: Vec<TotalsGrouping>,
pub show_averages: bool,
pub sort_field: TagSortField,
pub sort_order: TagSortOrder,
}
#[derive(Clone, Debug, Default)]
pub struct TagTotals {
earliest_date: Option<NaiveDate>,
latest_date: Option<NaiveDate>,
tags: BTreeMap<String, Duration>,
total: Duration,
}
impl TagTotals {
pub fn from_entries(entries: &[Entry]) -> Self {
let mut totals = Self::default();
for entry in entries {
totals.record(entry);
}
totals
}
pub fn is_empty(&self) -> bool {
self.tags.is_empty()
}
pub fn render_sorted(
&self,
sort_field: TagSortField,
sort_order: TagSortOrder,
duration_format: Option<DurationFormat>,
) -> String {
self.render_sorted_with_averages(sort_field, sort_order, duration_format, false)
}
pub fn render_sorted_with_averages(
&self,
sort_field: TagSortField,
sort_order: TagSortOrder,
duration_format: Option<DurationFormat>,
show_averages: bool,
) -> String {
if self.tags.is_empty() {
return String::new();
}
let format_duration = |d: Duration| -> String {
match duration_format {
Some(fmt) => FormattedDuration::new(d, fmt).to_string(),
None => format_tag_total(d),
}
};
let max_name_len = self.tags.keys().map(|k| k.len()).max().unwrap_or(0) + 1;
let mut sorted_tags: Vec<(&String, &Duration)> = self.tags.iter().collect();
match sort_field {
TagSortField::Name => sorted_tags.sort_by_key(|(a, _)| *a),
TagSortField::Time => sorted_tags.sort_by_key(|(_, a)| *a),
}
if sort_order == TagSortOrder::Desc {
sorted_tags.reverse();
}
let mut lines: Vec<String> = Vec::new();
lines.push("\n--- Tag Totals ---".into());
for (tag, duration) in &sorted_tags {
let padding = " ".repeat(max_name_len - tag.len());
lines.push(format!("{tag}:{padding}{}", format_duration(**duration)));
}
lines.push(String::new());
let total_str = format_duration(self.total);
if show_averages {
let day_span = self.day_span();
let avg = self.average_per_day(day_span);
lines.push(format!("Total tracked: {total_str} ({avg})"));
} else {
lines.push(format!("Total tracked: {total_str}"));
}
lines.join("\n")
}
fn average_per_day(&self, day_span: i64) -> String {
let total_minutes = self.total.num_minutes();
let avg_minutes = total_minutes as f64 / day_span as f64;
let hours = (avg_minutes / 60.0).floor() as i64;
let mins = (avg_minutes % 60.0).round() as i64;
if hours > 0 && mins > 0 {
format!("avg {hours}h {mins}m/day")
} else if hours > 0 {
format!("avg {hours}h/day")
} else {
format!("avg {mins}m/day")
}
}
fn day_span(&self) -> i64 {
match (self.earliest_date, self.latest_date) {
(Some(earliest), Some(latest)) => {
let span = (latest - earliest).num_days() + 1;
span.max(1)
}
_ => 1,
}
}
fn record(&mut self, entry: &Entry) {
let interval = match entry.interval() {
Some(d) if d > Duration::zero() => d,
_ => return,
};
self.total += interval;
let entry_date = entry.date().date_naive();
self.earliest_date = Some(match self.earliest_date {
Some(d) => d.min(entry_date),
None => entry_date,
});
self.latest_date = Some(match self.latest_date {
Some(d) => d.max(entry_date),
None => entry_date,
});
for tag in entry.tags().iter() {
let name = tag.name();
if name == "done" {
continue;
}
let current = self.tags.entry(name.to_lowercase()).or_insert(Duration::zero());
*current += interval;
}
}
}
#[derive(Clone, Debug, Default)]
pub struct SectionTotals {
sections: BTreeMap<String, Duration>,
total: Duration,
}
impl SectionTotals {
pub fn from_entries(entries: &[Entry]) -> Self {
let mut totals = Self::default();
for entry in entries {
let interval = match entry.interval() {
Some(d) if d > Duration::zero() => d,
_ => continue,
};
totals.total += interval;
let current = totals
.sections
.entry(entry.section().to_string())
.or_insert(Duration::zero());
*current += interval;
}
totals
}
pub fn is_empty(&self) -> bool {
self.sections.is_empty()
}
pub fn render(&self, duration_format: Option<DurationFormat>) -> String {
if self.sections.is_empty() {
return String::new();
}
let format_duration = |d: Duration| -> String {
match duration_format {
Some(fmt) => FormattedDuration::new(d, fmt).to_string(),
None => format_tag_total(d),
}
};
let max_name_len = self.sections.keys().map(|k| k.len()).max().unwrap_or(0) + 1;
let mut lines: Vec<String> = Vec::new();
lines.push("\n--- Section Totals ---".into());
let mut sorted: Vec<(&String, &Duration)> = self.sections.iter().collect();
sorted.sort_by_key(|(a, _)| a.to_lowercase());
for (section, duration) in &sorted {
let padding = " ".repeat(max_name_len - section.len());
lines.push(format!("{section}:{padding}{}", format_duration(**duration)));
}
lines.push(String::new());
lines.push(format!("Total tracked: {}", format_duration(self.total)));
lines.join("\n")
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TotalsGrouping {
Section,
Tags,
}
#[cfg(test)]
mod test {
use chrono::{Local, TimeZone};
use doing_taskpaper::{Note, Tag, Tags};
use super::*;
fn entry_with_tags(tag_names: &[&str], done_value: &str) -> Entry {
let date = Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap();
let mut tags: Vec<Tag> = tag_names.iter().map(|name| Tag::new(*name, None::<String>)).collect();
tags.push(Tag::new("done", Some(done_value)));
Entry::new(
date,
"test",
Tags::from_iter(tags),
Note::new(),
"Currently",
None::<String>,
)
}
mod from_entries {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn it_aggregates_time_per_tag() {
let entries = vec![
entry_with_tags(&["coding"], "2024-03-17 14:30"),
entry_with_tags(&["coding"], "2024-03-17 15:00"),
];
let totals = TagTotals::from_entries(&entries);
assert_eq!(totals.tags.len(), 1);
assert_eq!(totals.tags["coding"].num_minutes(), 90);
}
#[test]
fn it_excludes_done_tag() {
let entries = vec![entry_with_tags(&["coding"], "2024-03-17 14:30")];
let totals = TagTotals::from_entries(&entries);
assert!(!totals.tags.contains_key("done"));
}
#[test]
fn it_handles_multiple_tags() {
let entries = vec![entry_with_tags(&["coding", "rust"], "2024-03-17 15:00")];
let totals = TagTotals::from_entries(&entries);
assert_eq!(totals.tags.len(), 2);
assert_eq!(totals.tags["coding"].num_minutes(), 60);
assert_eq!(totals.tags["rust"].num_minutes(), 60);
}
#[test]
fn it_returns_empty_for_no_entries() {
let totals = TagTotals::from_entries(&[]);
assert!(totals.is_empty());
}
#[test]
fn it_tracks_total_time() {
let entries = vec![
entry_with_tags(&["coding"], "2024-03-17 14:30"),
entry_with_tags(&["writing"], "2024-03-17 15:00"),
];
let totals = TagTotals::from_entries(&entries);
assert_eq!(totals.total.num_minutes(), 90);
}
}
mod render {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn it_formats_tag_totals() {
let entries = vec![entry_with_tags(&["coding"], "2024-03-17 14:30")];
let totals = TagTotals::from_entries(&entries);
let output = totals.render_sorted(TagSortField::default(), TagSortOrder::default(), None);
assert!(output.contains("Tag Totals"));
assert!(output.contains("coding:"));
assert!(output.contains("Total tracked:"));
}
#[test]
fn it_returns_empty_for_no_data() {
let totals = TagTotals::default();
assert_eq!(
totals.render_sorted(TagSortField::default(), TagSortOrder::default(), None),
""
);
}
}
}