1use std::collections::BTreeMap;
2
3use chrono::{Duration, NaiveDate};
4use doing_taskpaper::Entry;
5use doing_time::{DurationFormat, FormattedDuration, format_tag_total};
6
7#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
9pub enum TagSortField {
10 #[default]
12 Name,
13 Time,
15}
16
17#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19pub enum TagSortOrder {
20 #[default]
22 Asc,
23 Desc,
25}
26
27#[derive(Clone, Debug, Default)]
29pub struct TotalsOptions {
30 pub duration_format: Option<DurationFormat>,
32 pub enabled: bool,
34 pub groupings: Vec<TotalsGrouping>,
36 pub show_averages: bool,
38 pub sort_field: TagSortField,
40 pub sort_order: TagSortOrder,
42}
43
44#[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 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 pub fn is_empty(&self) -> bool {
68 self.tags.is_empty()
69 }
70
71 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#[derive(Clone, Debug, Default)]
196pub struct SectionTotals {
197 sections: BTreeMap<String, Duration>,
198 total: Duration,
199}
200
201impl SectionTotals {
202 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 pub fn is_empty(&self) -> bool {
222 self.sections.is_empty()
223 }
224
225 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
260pub enum TotalsGrouping {
261 Section,
263 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}