Skip to main content

kanbrain_core/brain/
report.rs

1//! Time per task or tag for a period, with per-day/per-week breakdown and a session timeline.
2
3use 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    /// How report rows are grouped. The name is shown in titles and exports and typed as `--group`.
12    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13    pub enum Group {
14        /// Task rows, each showing its tags.
15        #[default]
16        Task => "task",
17        /// One row per tag; a task with several tags counts under each.
18        Tag => "tag",
19    }
20}
21
22/// What a report shows: which period, and how it's grouped and filtered.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ReportView {
25    /// The hour, day, week or month being reported.
26    pub period: Period,
27    /// Rows per task or per tag.
28    pub group: Group,
29    /// Only count tasks carrying this tag.
30    pub tag: Option<String>,
31    /// Only count tasks matching this search (see [`Task::matches`]); empty matches all.
32    pub query: String,
33}
34
35impl ReportView {
36    /// The report for `period`, grouped by task, unfiltered.
37    pub fn new(period: Period) -> Self {
38        Self { period, group: Group::default(), tag: None, query: String::new() }
39    }
40}
41
42/// Label for tasks without tags when grouping by tag.
43const UNTAGGED: &str = "(untagged)";
44
45/// Pauses between sessions at least this long are shown as untracked time.
46const MIN_GAP_MINUTES: i64 = 5;
47
48/// One session (clipped to the report's period) in the timeline.
49#[derive(Debug)]
50pub struct Slot {
51    /// When the session started (or the period started, if later).
52    pub start: DateTime<Local>,
53    /// `None` while still running.
54    pub end: Option<DateTime<Local>>,
55    /// Id of the task the session belongs to.
56    pub id: u32,
57    /// Title of the task.
58    pub title: String,
59    /// Tags of the task.
60    pub tags: Vec<String>,
61}
62
63/// One row of a report: a task or a tag, with its time per column and in total.
64#[derive(Debug)]
65pub struct ReportRow {
66    /// `#id title` or `#tag`.
67    pub label: String,
68    /// The task's tags; empty for tag rows.
69    pub tags: Vec<String>,
70    /// Time in the whole period.
71    pub total: Duration,
72    /// Time per breakdown column, parallel to `Report::columns`.
73    pub cells: Vec<Duration>,
74}
75
76/// A computed report, rendered both in the desktop app and as markdown (`export`).
77#[derive(Debug)]
78pub struct Report {
79    /// Real tracked time; when grouping by tag, rows can add up to more (multi-tag tasks).
80    pub total: Duration,
81    /// Tasks (or tags) with tracked time, busiest first.
82    pub rows: Vec<ReportRow>,
83    /// Breakdown column labels (days of a week, weeks of a month); empty for hour/day.
84    pub columns: Vec<String>,
85    /// Sum of each breakdown column over all tasks.
86    pub column_totals: Vec<Duration>,
87    /// Every session in the period, in start order.
88    pub timeline: Vec<Slot>,
89}
90
91impl Report {
92    /// Each timeline slot with the untracked gap (if long enough) right before it.
93    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    /// Several brains' reports for the same view as one. Task rows and timeline entries are named
109    /// after their project (`api · #3 Fix login`), since each brain numbers its entries from 1; tag
110    /// rows of the same tag are added up.
111    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
140/// Accumulates a row's total and per-column cells.
141struct RowSums {
142    /// Time in the whole period.
143    total: Duration,
144    /// Time per breakdown column.
145    cells: Vec<Duration>,
146    /// The task's tags (task rows only).
147    tags: Vec<String>,
148}
149
150impl RowSums {
151    /// An empty row for a report with `columns` breakdown columns.
152    fn new(columns: usize) -> Self {
153        Self { total: Duration::zero(), cells: vec![Duration::zero(); columns], tags: Vec::new() }
154    }
155
156    /// Adds one task's time to the row.
157    fn add(&mut self, total: Duration, cells: &[Duration]) {
158        self.total += total;
159        add_cells(&mut self.cells, cells);
160    }
161}
162
163/// Adds `cells` to `sums`, column by column.
164fn 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    /// Computes the report for `view`: rows, breakdown columns, totals and the timeline.
172    pub fn report(&self, view: &ReportView) -> Report {
173        let now = now();
174        let (from, to) = view.period.bounds();
175        // Breakdown columns, clipped to the period (a month's first and last weeks).
176        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        // Keyed by label; BTreeMap keeps rows with equal time in a stable order.
189        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
225/// The rows a task counts toward: itself, or each of its tags.
226fn 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
234/// The task's sessions that fall in `[from, to)`, clipped to it.
235fn 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}