Skip to main content

doing_template/
totals.rs

1use std::collections::BTreeMap;
2
3use chrono::{Duration, NaiveDate};
4use doing_taskpaper::Entry;
5use doing_time::{DurationFormat, FormattedDuration, format_tag_total};
6
7/// How tags are sorted in the totals section.
8#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
9pub enum TagSortField {
10  /// Sort tags alphabetically by name.
11  #[default]
12  Name,
13  /// Sort tags by total time.
14  Time,
15}
16
17/// Sort order for tag totals.
18#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19pub enum TagSortOrder {
20  /// Sort in ascending order.
21  #[default]
22  Asc,
23  /// Sort in descending order.
24  Desc,
25}
26
27/// Options controlling how tag totals are rendered.
28#[derive(Clone, Debug, Default)]
29pub struct TotalsOptions {
30  /// The duration format to use for totals display.
31  pub duration_format: Option<DurationFormat>,
32  /// Whether to show tag totals.
33  pub enabled: bool,
34  /// Which groupings to show (defaults to tags only).
35  pub groupings: Vec<TotalsGrouping>,
36  /// Whether to show average hours per day alongside totals.
37  pub show_averages: bool,
38  /// How to sort tags.
39  pub sort_field: TagSortField,
40  /// Sort direction.
41  pub sort_order: TagSortOrder,
42}
43
44/// Aggregated time totals per tag.
45#[derive(Clone, Debug, Default)]
46pub struct TagTotals {
47  earliest_date: Option<NaiveDate>,
48  latest_date: Option<NaiveDate>,
49  tags: BTreeMap<String, Duration>,
50  total: Duration,
51}
52
53impl TagTotals {
54  /// Build tag totals from a slice of entries.
55  ///
56  /// Each entry's interval is attributed to every non-`done` tag on that entry.
57  /// The `done` tag's time is rolled into the `"All"` total instead.
58  pub fn from_entries(entries: &[Entry]) -> Self {
59    let mut totals = Self::default();
60    for entry in entries {
61      totals.record(entry);
62    }
63    totals
64  }
65
66  /// Return true if no time has been recorded.
67  pub fn is_empty(&self) -> bool {
68    self.tags.is_empty()
69  }
70
71  /// Render the tag totals as a formatted text block, sorted by the given field and order.
72  ///
73  /// Output format:
74  /// ```text
75  /// --- Tag Totals ---
76  /// coding:  01:02:30
77  /// writing: 00:30:00
78  ///
79  /// Total tracked: 01:32:30
80  /// ```
81  pub fn render_sorted(
82    &self,
83    sort_field: TagSortField,
84    sort_order: TagSortOrder,
85    duration_format: Option<DurationFormat>,
86  ) -> String {
87    self.render_sorted_with_averages(sort_field, sort_order, duration_format, false)
88  }
89
90  pub fn render_sorted_with_averages(
91    &self,
92    sort_field: TagSortField,
93    sort_order: TagSortOrder,
94    duration_format: Option<DurationFormat>,
95    show_averages: bool,
96  ) -> String {
97    if self.tags.is_empty() {
98      return String::new();
99    }
100
101    let format_duration = |d: Duration| -> String {
102      match duration_format {
103        Some(fmt) => FormattedDuration::new(d, fmt).to_string(),
104        None => format_tag_total(d),
105      }
106    };
107
108    let max_name_len = self.tags.keys().map(|k| k.len()).max().unwrap_or(0) + 1;
109
110    let mut sorted_tags: Vec<(&String, &Duration)> = self.tags.iter().collect();
111    match sort_field {
112      TagSortField::Name => sorted_tags.sort_by_key(|(a, _)| *a),
113      TagSortField::Time => sorted_tags.sort_by_key(|(_, a)| *a),
114    }
115    if sort_order == TagSortOrder::Desc {
116      sorted_tags.reverse();
117    }
118
119    let mut lines: Vec<String> = Vec::new();
120    lines.push("\n--- Tag Totals ---".into());
121
122    for (tag, duration) in &sorted_tags {
123      let padding = " ".repeat(max_name_len - tag.len());
124      lines.push(format!("{tag}:{padding}{}", format_duration(**duration)));
125    }
126
127    lines.push(String::new());
128
129    let total_str = format_duration(self.total);
130    if show_averages {
131      let day_span = self.day_span();
132      let avg = self.average_per_day(day_span);
133      lines.push(format!("Total tracked: {total_str} ({avg})"));
134    } else {
135      lines.push(format!("Total tracked: {total_str}"));
136    }
137
138    lines.join("\n")
139  }
140
141  fn average_per_day(&self, day_span: i64) -> String {
142    let total_minutes = self.total.num_minutes();
143    let avg_minutes = total_minutes as f64 / day_span as f64;
144    let hours = (avg_minutes / 60.0).floor() as i64;
145    let mins = (avg_minutes % 60.0).round() as i64;
146    if hours > 0 && mins > 0 {
147      format!("avg {hours}h {mins}m/day")
148    } else if hours > 0 {
149      format!("avg {hours}h/day")
150    } else {
151      format!("avg {mins}m/day")
152    }
153  }
154
155  fn day_span(&self) -> i64 {
156    match (self.earliest_date, self.latest_date) {
157      (Some(earliest), Some(latest)) => {
158        let span = (latest - earliest).num_days() + 1;
159        span.max(1)
160      }
161      _ => 1,
162    }
163  }
164
165  fn record(&mut self, entry: &Entry) {
166    let interval = match entry.interval() {
167      Some(d) if d > Duration::zero() => d,
168      _ => return,
169    };
170
171    self.total += interval;
172
173    let entry_date = entry.date().date_naive();
174    self.earliest_date = Some(match self.earliest_date {
175      Some(d) => d.min(entry_date),
176      None => entry_date,
177    });
178    self.latest_date = Some(match self.latest_date {
179      Some(d) => d.max(entry_date),
180      None => entry_date,
181    });
182
183    for tag in entry.tags().iter() {
184      let name = tag.name();
185      if name == "done" {
186        continue;
187      }
188      let current = self.tags.entry(name.to_lowercase()).or_insert(Duration::zero());
189      *current += interval;
190    }
191  }
192}
193
194/// Aggregated time totals per section.
195#[derive(Clone, Debug, Default)]
196pub struct SectionTotals {
197  sections: BTreeMap<String, Duration>,
198  total: Duration,
199}
200
201impl SectionTotals {
202  /// Build section totals from a slice of entries.
203  pub fn from_entries(entries: &[Entry]) -> Self {
204    let mut totals = Self::default();
205    for entry in entries {
206      let interval = match entry.interval() {
207        Some(d) if d > Duration::zero() => d,
208        _ => continue,
209      };
210      totals.total += interval;
211      let current = totals
212        .sections
213        .entry(entry.section().to_string())
214        .or_insert(Duration::zero());
215      *current += interval;
216    }
217    totals
218  }
219
220  /// Return true if no time has been recorded.
221  pub fn is_empty(&self) -> bool {
222    self.sections.is_empty()
223  }
224
225  /// Render the section totals as a formatted text block.
226  pub fn render(&self, duration_format: Option<DurationFormat>) -> String {
227    if self.sections.is_empty() {
228      return String::new();
229    }
230
231    let format_duration = |d: Duration| -> String {
232      match duration_format {
233        Some(fmt) => FormattedDuration::new(d, fmt).to_string(),
234        None => format_tag_total(d),
235      }
236    };
237
238    let max_name_len = self.sections.keys().map(|k| k.len()).max().unwrap_or(0) + 1;
239
240    let mut lines: Vec<String> = Vec::new();
241    lines.push("\n--- Section Totals ---".into());
242
243    let mut sorted: Vec<(&String, &Duration)> = self.sections.iter().collect();
244    sorted.sort_by_key(|(a, _)| a.to_lowercase());
245
246    for (section, duration) in &sorted {
247      let padding = " ".repeat(max_name_len - section.len());
248      lines.push(format!("{section}:{padding}{}", format_duration(**duration)));
249    }
250
251    lines.push(String::new());
252    lines.push(format!("Total tracked: {}", format_duration(self.total)));
253
254    lines.join("\n")
255  }
256}
257
258/// How to group totals.
259#[derive(Clone, Copy, Debug, Eq, PartialEq)]
260pub enum TotalsGrouping {
261  /// Group by section name.
262  Section,
263  /// Group by tag (default).
264  Tags,
265}
266
267#[cfg(test)]
268mod test {
269  use chrono::{Local, TimeZone};
270  use doing_taskpaper::{Note, Tag, Tags};
271
272  use super::*;
273
274  fn entry_with_tags(tag_names: &[&str], done_value: &str) -> Entry {
275    let date = Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap();
276    let mut tags: Vec<Tag> = tag_names.iter().map(|name| Tag::new(*name, None::<String>)).collect();
277    tags.push(Tag::new("done", Some(done_value)));
278    Entry::new(
279      date,
280      "test",
281      Tags::from_iter(tags),
282      Note::new(),
283      "Currently",
284      None::<String>,
285    )
286  }
287
288  mod from_entries {
289    use pretty_assertions::assert_eq;
290
291    use super::*;
292
293    #[test]
294    fn it_aggregates_time_per_tag() {
295      let entries = vec![
296        entry_with_tags(&["coding"], "2024-03-17 14:30"),
297        entry_with_tags(&["coding"], "2024-03-17 15:00"),
298      ];
299
300      let totals = TagTotals::from_entries(&entries);
301
302      assert_eq!(totals.tags.len(), 1);
303      assert_eq!(totals.tags["coding"].num_minutes(), 90);
304    }
305
306    #[test]
307    fn it_excludes_done_tag() {
308      let entries = vec![entry_with_tags(&["coding"], "2024-03-17 14:30")];
309
310      let totals = TagTotals::from_entries(&entries);
311
312      assert!(!totals.tags.contains_key("done"));
313    }
314
315    #[test]
316    fn it_handles_multiple_tags() {
317      let entries = vec![entry_with_tags(&["coding", "rust"], "2024-03-17 15:00")];
318
319      let totals = TagTotals::from_entries(&entries);
320
321      assert_eq!(totals.tags.len(), 2);
322      assert_eq!(totals.tags["coding"].num_minutes(), 60);
323      assert_eq!(totals.tags["rust"].num_minutes(), 60);
324    }
325
326    #[test]
327    fn it_returns_empty_for_no_entries() {
328      let totals = TagTotals::from_entries(&[]);
329
330      assert!(totals.is_empty());
331    }
332
333    #[test]
334    fn it_tracks_total_time() {
335      let entries = vec![
336        entry_with_tags(&["coding"], "2024-03-17 14:30"),
337        entry_with_tags(&["writing"], "2024-03-17 15:00"),
338      ];
339
340      let totals = TagTotals::from_entries(&entries);
341
342      assert_eq!(totals.total.num_minutes(), 90);
343    }
344  }
345
346  mod render {
347    use pretty_assertions::assert_eq;
348
349    use super::*;
350
351    #[test]
352    fn it_formats_tag_totals() {
353      let entries = vec![entry_with_tags(&["coding"], "2024-03-17 14:30")];
354
355      let totals = TagTotals::from_entries(&entries);
356      let output = totals.render_sorted(TagSortField::default(), TagSortOrder::default(), None);
357
358      assert!(output.contains("Tag Totals"));
359      assert!(output.contains("coding:"));
360      assert!(output.contains("Total tracked:"));
361    }
362
363    #[test]
364    fn it_returns_empty_for_no_data() {
365      let totals = TagTotals::default();
366
367      assert_eq!(
368        totals.render_sorted(TagSortField::default(), TagSortOrder::default(), None),
369        ""
370      );
371    }
372  }
373}