1use super::Brain;
4use crate::enums::named_enum;
5use crate::model::{Session, Task, covered, 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 pub project: Option<String>,
63}
64
65impl Slot {
66 pub fn label(&self) -> String {
68 match &self.project {
69 Some(project) => format!("{project} #{} {}", self.id, self.title),
70 None => format!("#{} {}", self.id, self.title),
71 }
72 }
73}
74
75#[derive(Debug)]
77pub struct ReportRow {
78 pub label: String,
80 pub tags: Vec<String>,
82 pub total: Duration,
84 pub cells: Vec<Duration>,
86}
87
88#[derive(Debug)]
90pub struct Report {
91 pub total: Duration,
94 pub rows: Vec<ReportRow>,
96 pub columns: Vec<String>,
98 pub column_totals: Vec<Duration>,
100 pub timeline: Vec<Slot>,
102}
103
104impl Report {
105 pub fn timeline_with_gaps(&self, now: DateTime<Local>) -> Vec<(Option<Duration>, &Slot)> {
107 let mut prev_end: Option<DateTime<Local>> = None;
108 self.timeline
109 .iter()
110 .map(|slot| {
111 let gap = prev_end.map(|p| slot.start - p).filter(|g| *g >= Duration::minutes(MIN_GAP_MINUTES));
112 let end = slot.end.unwrap_or(now);
113 prev_end = Some(prev_end.map_or(end, |p| p.max(end)));
114 (gap, slot)
115 })
116 .collect()
117 }
118}
119
120impl Report {
121 pub fn merge(parts: Vec<(Option<String>, Report)>, view: &ReportView) -> Report {
125 let timeline = parts
126 .into_iter()
127 .flat_map(|(project, report)| {
128 report.timeline.into_iter().map(move |slot| Slot { project: project.clone(), ..slot })
129 })
130 .collect();
131 Report::from_timeline(timeline, view, now())
132 }
133
134 fn from_timeline(mut timeline: Vec<Slot>, view: &ReportView, now: DateTime<Local>) -> Report {
136 let (from, to) = view.period.bounds();
137 let (columns, ranges): (Vec<String>, Vec<_>) = view
139 .period
140 .columns()
141 .into_iter()
142 .map(|(label, p)| {
143 let (a, b) = p.bounds();
144 (label, (a.max(from), b.min(to)))
145 })
146 .unzip();
147 timeline.sort_by_key(|s| s.start);
148 let time = |slots: &[&Slot], (a, b): (DateTime<Local>, DateTime<Local>)| {
149 covered(slots.iter().map(|s| Session { start: s.start, end: s.end }.span(a, b, now)))
150 };
151 let all: Vec<&Slot> = timeline.iter().collect();
152 let mut groups: BTreeMap<String, (Vec<&Slot>, Vec<String>)> = BTreeMap::new();
154 for slot in &timeline {
155 for label in row_labels(slot, view.group) {
156 let group = groups.entry(label).or_default();
157 group.0.push(slot);
158 if view.group == Group::Task {
159 group.1.clone_from(&slot.tags);
160 }
161 }
162 }
163 let mut rows: Vec<ReportRow> = groups
164 .into_iter()
165 .map(|(label, (slots, tags))| ReportRow {
166 label,
167 tags,
168 total: time(&slots, (from, to)),
169 cells: ranges.iter().map(|&range| time(&slots, range)).collect(),
170 })
171 .collect();
172 rows.sort_by_key(|r| std::cmp::Reverse(r.total));
173 Report {
174 total: time(&all, (from, to)),
175 column_totals: ranges.iter().map(|&range| time(&all, range)).collect(),
176 rows,
177 columns,
178 timeline,
179 }
180 }
181}
182
183impl Brain {
184 pub fn report(&self, view: &ReportView) -> Report {
186 let now = now();
187 let (from, to) = view.period.bounds();
188 let timeline = self
189 .tasks
190 .iter()
191 .filter(|t| view.tag.as_ref().is_none_or(|tag| t.tags.contains(tag)) && t.matches(&view.query))
192 .flat_map(|task| slots(task, from, to, now))
193 .collect();
194 Report::from_timeline(timeline, view, now)
195 }
196}
197
198fn row_labels(slot: &Slot, group: Group) -> Vec<String> {
200 match group {
201 Group::Task => vec![slot.label()],
202 Group::Tag if slot.tags.is_empty() => vec![UNTAGGED.to_string()],
203 Group::Tag => slot.tags.iter().map(|tag| format!("#{tag}")).collect(),
204 }
205}
206
207fn slots(
209 task: &Task,
210 from: DateTime<Local>,
211 to: DateTime<Local>,
212 now: DateTime<Local>,
213) -> impl Iterator<Item = Slot> + '_ {
214 task.sessions.iter().filter(move |s| !s.overlap(from, to, now).is_zero()).map(move |s| Slot {
215 start: s.start.max(from),
216 end: s.end.map(|e| e.min(to)),
217 id: task.id,
218 title: task.title.clone(),
219 tags: task.tags.clone(),
220 project: None,
221 })
222}