1use super::Brain;
4use crate::enums::named_enum;
5use crate::model::{Task, now};
6use crate::period::Period;
7use chrono::{DateTime, Duration, Local};
8use std::collections::BTreeMap;
9
10named_enum! {
11 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13 pub enum Group {
14 #[default]
16 Task => "task",
17 Tag => "tag",
19 }
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ReportView {
25 pub period: Period,
27 pub group: Group,
29 pub tag: Option<String>,
31 pub query: String,
33}
34
35impl ReportView {
36 pub fn new(period: Period) -> Self {
38 Self { period, group: Group::default(), tag: None, query: String::new() }
39 }
40}
41
42const UNTAGGED: &str = "(untagged)";
44
45const MIN_GAP_MINUTES: i64 = 5;
47
48#[derive(Debug)]
50pub struct Slot {
51 pub start: DateTime<Local>,
53 pub end: Option<DateTime<Local>>,
55 pub id: u32,
57 pub title: String,
59 pub tags: Vec<String>,
61}
62
63#[derive(Debug)]
65pub struct ReportRow {
66 pub label: String,
68 pub tags: Vec<String>,
70 pub total: Duration,
72 pub cells: Vec<Duration>,
74}
75
76#[derive(Debug)]
78pub struct Report {
79 pub total: Duration,
81 pub rows: Vec<ReportRow>,
83 pub columns: Vec<String>,
85 pub column_totals: Vec<Duration>,
87 pub timeline: Vec<Slot>,
89}
90
91impl Report {
92 pub fn timeline_with_gaps(&self, now: DateTime<Local>) -> Vec<(Option<Duration>, &Slot)> {
94 let mut prev_end: Option<DateTime<Local>> = None;
95 self.timeline
96 .iter()
97 .map(|slot| {
98 let gap = prev_end.map(|p| slot.start - p).filter(|g| *g >= Duration::minutes(MIN_GAP_MINUTES));
99 let end = slot.end.unwrap_or(now);
100 prev_end = Some(prev_end.map_or(end, |p| p.max(end)));
101 (gap, slot)
102 })
103 .collect()
104 }
105}
106
107impl Report {
108 pub fn merge(parts: Vec<(Option<String>, Report)>, group: Group) -> Report {
112 let columns = parts.first().map(|(_, r)| r.columns.clone()).unwrap_or_default();
113 let mut total = Duration::zero();
114 let mut column_totals = vec![Duration::zero(); columns.len()];
115 let mut rows: BTreeMap<String, ReportRow> = BTreeMap::new();
116 let mut timeline = Vec::new();
117 for (project, report) in parts {
118 let named = |label: String| project.as_ref().map_or_else(|| label.clone(), |p| format!("{p} · {label}"));
119 total += report.total;
120 add_cells(&mut column_totals, &report.column_totals);
121 for row in report.rows {
122 let label = if group == Group::Task { named(row.label) } else { row.label };
123 match rows.get_mut(&label) {
124 Some(sum) => {
125 sum.total += row.total;
126 add_cells(&mut sum.cells, &row.cells);
127 }
128 None => _ = rows.insert(label.clone(), ReportRow { label, ..row }),
129 }
130 }
131 timeline.extend(report.timeline.into_iter().map(|slot| Slot { title: named(slot.title.clone()), ..slot }));
132 }
133 let mut rows: Vec<ReportRow> = rows.into_values().collect();
134 rows.sort_by_key(|r| std::cmp::Reverse(r.total));
135 timeline.sort_by_key(|s| s.start);
136 Report { total, rows, columns, column_totals, timeline }
137 }
138}
139
140struct RowSums {
142 total: Duration,
144 cells: Vec<Duration>,
146 tags: Vec<String>,
148}
149
150impl RowSums {
151 fn new(columns: usize) -> Self {
153 Self { total: Duration::zero(), cells: vec![Duration::zero(); columns], tags: Vec::new() }
154 }
155
156 fn add(&mut self, total: Duration, cells: &[Duration]) {
158 self.total += total;
159 add_cells(&mut self.cells, cells);
160 }
161}
162
163fn add_cells(sums: &mut [Duration], cells: &[Duration]) {
165 for (sum, cell) in sums.iter_mut().zip(cells) {
166 *sum += *cell;
167 }
168}
169
170impl Brain {
171 pub fn report(&self, view: &ReportView) -> Report {
173 let now = now();
174 let (from, to) = view.period.bounds();
175 let (labels, ranges): (Vec<String>, Vec<_>) = view
177 .period
178 .columns()
179 .into_iter()
180 .map(|(label, p)| {
181 let (a, b) = p.bounds();
182 (label, (a.max(from), b.min(to)))
183 })
184 .unzip();
185
186 let mut total = Duration::zero();
187 let mut column_totals = vec![Duration::zero(); labels.len()];
188 let mut rows: BTreeMap<String, RowSums> = BTreeMap::new();
190 let mut timeline = Vec::new();
191
192 let tasks = self
193 .tasks
194 .iter()
195 .filter(|t| view.tag.as_ref().is_none_or(|tag| t.tags.contains(tag)) && t.matches(&view.query));
196 for task in tasks {
197 let task_total = task.time_between(from, to, now);
198 if task_total.is_zero() {
199 continue;
200 }
201 let cells: Vec<Duration> = ranges.iter().map(|&(a, b)| task.time_between(a, b, now)).collect();
202 total += task_total;
203 add_cells(&mut column_totals, &cells);
204
205 for label in row_labels(task, view.group) {
206 let row = rows.entry(label).or_insert_with(|| RowSums::new(ranges.len()));
207 if view.group == Group::Task {
208 row.tags.clone_from(&task.tags);
209 }
210 row.add(task_total, &cells);
211 }
212 timeline.extend(slots(task, from, to, now));
213 }
214
215 let mut rows: Vec<ReportRow> = rows
216 .into_iter()
217 .map(|(label, sums)| ReportRow { label, tags: sums.tags, total: sums.total, cells: sums.cells })
218 .collect();
219 rows.sort_by_key(|r| std::cmp::Reverse(r.total));
220 timeline.sort_by_key(|s| s.start);
221 Report { total, rows, columns: labels, column_totals, timeline }
222 }
223}
224
225fn row_labels(task: &Task, group: Group) -> Vec<String> {
227 match group {
228 Group::Task => vec![format!("#{} {}", task.id, task.title)],
229 Group::Tag if task.tags.is_empty() => vec![UNTAGGED.to_string()],
230 Group::Tag => task.tags.iter().map(|tag| format!("#{tag}")).collect(),
231 }
232}
233
234fn slots(
236 task: &Task,
237 from: DateTime<Local>,
238 to: DateTime<Local>,
239 now: DateTime<Local>,
240) -> impl Iterator<Item = Slot> + '_ {
241 task.sessions.iter().filter(move |s| !s.overlap(from, to, now).is_zero()).map(move |s| Slot {
242 start: s.start.max(from),
243 end: s.end.map(|e| e.min(to)),
244 id: task.id,
245 title: task.title.clone(),
246 tags: task.tags.clone(),
247 })
248}