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::{Session, Task, covered, 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    /// The brain the task is in, when a report merges several, which number their entries alike.
62    pub project: Option<String>,
63}
64
65impl Slot {
66    /// `#id title`, after its brain when the report merges several: `api #3 Fix login`, as the app names entries.
67    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/// One row of a report: a task or a tag, with its time per column and in total.
76#[derive(Debug)]
77pub struct ReportRow {
78    /// `#id title` or `#tag`.
79    pub label: String,
80    /// The task's tags; empty for tag rows.
81    pub tags: Vec<String>,
82    /// Time in the whole period; for a tag, time two of its tasks ran at once counts once.
83    pub total: Duration,
84    /// Time per breakdown column, parallel to `Report::columns`.
85    pub cells: Vec<Duration>,
86}
87
88/// A computed report, rendered both in the desktop app and as markdown (`export`).
89#[derive(Debug)]
90pub struct Report {
91    /// Time worked, by the clock: when tasks ran at once (an agent's subagents, say) that time counts once,
92    /// so task rows can add up to more, and tag rows too (a task with several tags counts under each).
93    pub total: Duration,
94    /// Tasks (or tags) with tracked time, busiest first.
95    pub rows: Vec<ReportRow>,
96    /// Breakdown column labels (days of a week, weeks of a month); empty for hour/day.
97    pub columns: Vec<String>,
98    /// Time worked in each breakdown column, counted like `total`.
99    pub column_totals: Vec<Duration>,
100    /// Every session in the period, in start order.
101    pub timeline: Vec<Slot>,
102}
103
104impl Report {
105    /// Each timeline slot with the untracked gap (if long enough) right before it.
106    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    /// Several brains' reports for the same view as one, worked out again from their sessions so time
122    /// tasks of different brains ran at once counts once too. Task rows and timeline entries are named
123    /// after their brain (`api #3 Fix login`), since each brain numbers its entries from 1.
124    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    /// The report of `timeline`, every session in `view`'s period: rows, breakdown columns and totals.
135    fn from_timeline(mut timeline: Vec<Slot>, view: &ReportView, now: DateTime<Local>) -> Report {
136        let (from, to) = view.period.bounds();
137        // Breakdown columns, clipped to the period (a month's first and last weeks).
138        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        // Keyed by label; BTreeMap keeps rows with equal time in a stable order.
153        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    /// Computes the report for `view`: rows, breakdown columns, totals and the timeline.
185    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
198/// The rows a session counts toward: its task, or each of its task's tags.
199fn 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
207/// The task's sessions that fall in `[from, to)`, clipped to it.
208fn 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}