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