doing-template 0.1.10

Template parsing and rendering for the doing CLI
Documentation
use std::collections::BTreeMap;

use chrono::{Duration, NaiveDate};
use doing_taskpaper::Entry;
use doing_time::{DurationFormat, FormattedDuration, format_tag_total};

/// How tags are sorted in the totals section.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum TagSortField {
  /// Sort tags alphabetically by name.
  #[default]
  Name,
  /// Sort tags by total time.
  Time,
}

/// Sort order for tag totals.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum TagSortOrder {
  /// Sort in ascending order.
  #[default]
  Asc,
  /// Sort in descending order.
  Desc,
}

/// Options controlling how tag totals are rendered.
#[derive(Clone, Debug, Default)]
pub struct TotalsOptions {
  /// The duration format to use for totals display.
  pub duration_format: Option<DurationFormat>,
  /// Whether to show tag totals.
  pub enabled: bool,
  /// Which groupings to show (defaults to tags only).
  pub groupings: Vec<TotalsGrouping>,
  /// Whether to show average hours per day alongside totals.
  pub show_averages: bool,
  /// How to sort tags.
  pub sort_field: TagSortField,
  /// Sort direction.
  pub sort_order: TagSortOrder,
}

/// Aggregated time totals per tag.
#[derive(Clone, Debug, Default)]
pub struct TagTotals {
  earliest_date: Option<NaiveDate>,
  latest_date: Option<NaiveDate>,
  tags: BTreeMap<String, Duration>,
  total: Duration,
}

impl TagTotals {
  /// Build tag totals from a slice of entries.
  ///
  /// Each entry's interval is attributed to every non-`done` tag on that entry.
  /// The `done` tag's time is rolled into the `"All"` total instead.
  pub fn from_entries(entries: &[Entry]) -> Self {
    let mut totals = Self::default();
    for entry in entries {
      totals.record(entry);
    }
    totals
  }

  /// Return true if no time has been recorded.
  pub fn is_empty(&self) -> bool {
    self.tags.is_empty()
  }

  /// Render the tag totals as a formatted text block, sorted by the given field and order.
  ///
  /// Output format:
  /// ```text
  /// --- Tag Totals ---
  /// coding:  01:02:30
  /// writing: 00:30:00
  ///
  /// Total tracked: 01:32:30
  /// ```
  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;
    }
  }
}

/// Aggregated time totals per section.
#[derive(Clone, Debug, Default)]
pub struct SectionTotals {
  sections: BTreeMap<String, Duration>,
  total: Duration,
}

impl SectionTotals {
  /// Build section totals from a slice of entries.
  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
  }

  /// Return true if no time has been recorded.
  pub fn is_empty(&self) -> bool {
    self.sections.is_empty()
  }

  /// Render the section totals as a formatted text block.
  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")
  }
}

/// How to group totals.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TotalsGrouping {
  /// Group by section name.
  Section,
  /// Group by tag (default).
  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),
        ""
      );
    }
  }
}