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, 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`.
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    /// Where the slot starts and ends within `[from, to)`, a running one ending `now`.
75    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/// One row of a report: a task or a tag, with its time per column and in total.
86#[derive(Debug)]
87pub struct ReportRow {
88    /// `#id title` or `#tag`.
89    pub label: String,
90    /// The task's tags; empty for tag rows.
91    pub tags: Vec<String>,
92    /// Time in the whole period; for a tag, time two of its tasks ran at once counts once.
93    pub total: Duration,
94    /// Time per breakdown column, parallel to `Report::columns`.
95    pub cells: Vec<Duration>,
96}
97
98/// A computed report, rendered both in the desktop app and as markdown (`export`).
99#[derive(Debug)]
100pub struct Report {
101    /// Time worked, by the clock: when tasks ran at once (an agent's subagents, say) that time counts once,
102    /// so task rows can add up to more, and tag rows too (a task with several tags counts under each).
103    pub total: Duration,
104    /// Tasks (or tags) with tracked time, busiest first.
105    pub rows: Vec<ReportRow>,
106    /// Breakdown column labels (days of a week, weeks of a month); empty for hour/day.
107    pub columns: Vec<String>,
108    /// Time worked in each breakdown column, counted like `total`.
109    pub column_totals: Vec<Duration>,
110    /// Every session in the period, in start order.
111    pub timeline: Vec<Slot>,
112}
113
114impl Report {
115    /// Each timeline slot with the untracked gap (if long enough) right before it.
116    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    /// Several brains' reports for the same view as one, worked out again from their sessions so time
132    /// tasks of different brains ran at once counts once too. Task rows and timeline entries are named
133    /// after their brain (`api · #3 Fix login`), since each brain numbers its entries from 1.
134    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    /// The report of `timeline`, every session in `view`'s period: rows, breakdown columns and totals.
145    fn from_timeline(mut timeline: Vec<Slot>, view: &ReportView, now: DateTime<Local>) -> Report {
146        let (from, to) = view.period.bounds();
147        // Breakdown columns, clipped to the period (a month's first and last weeks).
148        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        // Keyed by label; BTreeMap keeps rows with equal time in a stable order.
163        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    /// Computes the report for `view`: rows, breakdown columns, totals and the timeline.
195    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
208/// The rows a session counts toward: its task, or each of its task's tags.
209fn 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
217/// The task's sessions that fall in `[from, to)`, clipped to it.
218fn 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}