Skip to main content

kanbrain_core/brain/
calendar.rs

1//! What happened on each day of a period, for the calendar: time worked, status changes, comments
2//! and new entries. Filtered like the report, by period and tag, and also by kind of interaction.
3//! Each entry shows once a day, summed up by its last status change (or else what else happened to
4//! it), with the time worked on it and its comments that day: a day of interruptions would
5//! otherwise list every park and resume.
6
7use super::Brain;
8use crate::enums::named_enum;
9use crate::model::{Kind, Status, Task, covered, now};
10use crate::period::{Period, Unit};
11use chrono::{DateTime, Duration, Local, NaiveDate, NaiveTime};
12
13named_enum! {
14    /// Which interactions the calendar shows. `next()` cycles through them.
15    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16    pub enum Show {
17        /// Every kind.
18        #[default]
19        All => "all",
20        /// Time worked: the sessions.
21        Time => "time",
22        /// Status changes.
23        Moves => "moves",
24        /// Comments written.
25        Comments => "comments",
26        /// Entries created.
27        New => "new",
28    }
29}
30
31/// What the calendar shows: which period, and which tag and kind of interaction.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct CalendarView {
34    /// The day, week or month shown. An hour shows as its whole day.
35    pub period: Period,
36    /// Only entries carrying this tag.
37    pub tag: Option<String>,
38    /// Only entries matching this search (see [`Task::matches`]); empty matches all.
39    pub query: String,
40    /// Only this kind of interaction.
41    pub show: Show,
42}
43
44impl CalendarView {
45    /// Every interaction in `period`.
46    pub fn new(period: Period) -> Self {
47        Self { period, tag: None, query: String::new(), show: Show::All }
48    }
49}
50
51/// What happened in an interaction.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum What {
54    /// A session, clipped to its day. `end` is `None` while it's still running.
55    Worked {
56        /// When it stopped, or the end of the day if it went on past midnight.
57        end: Option<DateTime<Local>>,
58    },
59    /// The task's status changed.
60    Moved {
61        /// The status before.
62        from: Status,
63        /// The status after.
64        to: Status,
65    },
66    /// A comment was written.
67    Commented {
68        /// The comment's first non-empty line.
69        excerpt: String,
70    },
71    /// The entry was created.
72    Created,
73}
74
75impl What {
76    /// Whether `show` includes this kind of interaction.
77    fn shown_by(&self, show: Show) -> bool {
78        matches!(
79            (show, self),
80            (Show::All, _)
81                | (Show::Time, Self::Worked { .. })
82                | (Show::Moves, Self::Moved { .. })
83                | (Show::Comments, Self::Commented { .. })
84                | (Show::New, Self::Created)
85        )
86    }
87}
88
89/// One interaction with an entry.
90#[derive(Debug, Clone)]
91pub struct Event {
92    /// When it happened; for a session, when it started that day.
93    pub at: DateTime<Local>,
94    /// The entry's id.
95    pub id: u32,
96    /// The entry's title.
97    pub title: String,
98    /// Task, note or decision.
99    pub kind: Kind,
100    /// The entry's tags.
101    pub tags: Vec<String>,
102    /// What happened.
103    pub what: What,
104    /// Which brain it's from, as the index of its calendar in [`Calendar::merge`]; 0 otherwise.
105    pub source: usize,
106    /// Time worked on the entry that day, up to now while it runs.
107    pub worked: Duration,
108    /// Comments written on the entry that day.
109    pub comments: usize,
110}
111
112/// One day of the calendar.
113#[derive(Debug)]
114pub struct Day {
115    /// The date.
116    pub date: NaiveDate,
117    /// Whether it's in the period. A month's grid starts on a Monday and ends on a Sunday, so its
118    /// first and last weeks have days of the months around it, which stay empty.
119    pub in_period: bool,
120    /// Time worked that day on the entries the tag filter keeps, by the clock: tasks that ran at once
121    /// count that time once.
122    pub tracked: Duration,
123    /// One summary per entry with interactions shown, in time order.
124    pub events: Vec<Event>,
125    /// The day's sessions, clipped to it, from which `tracked` is worked out again when calendars merge.
126    worked: Vec<(DateTime<Local>, DateTime<Local>)>,
127}
128
129/// A computed calendar: whole weeks for a month, otherwise the period's days.
130#[derive(Debug)]
131pub struct Calendar {
132    /// Every day of the grid, in order.
133    pub days: Vec<Day>,
134    /// Time worked in the period, counted like each day's.
135    pub tracked: Duration,
136}
137
138impl Calendar {
139    /// How many interactions are shown.
140    pub fn events(&self) -> usize {
141        self.days.iter().map(|d| d.events.len()).sum()
142    }
143
144    /// Several brains' calendars for the same view as one: each event says which it came from, as
145    /// its calendar's index in `parts`.
146    pub fn merge(parts: Vec<Calendar>) -> Calendar {
147        let mut parts = parts.into_iter().enumerate();
148        let Some((_, mut merged)) = parts.next() else {
149            return Calendar { days: Vec::new(), tracked: Duration::zero() };
150        };
151        for (source, calendar) in parts {
152            for (day, other) in merged.days.iter_mut().zip(calendar.days) {
153                day.worked.extend(other.worked);
154                day.tracked = covered(day.worked.iter().copied());
155                day.events.extend(other.events.into_iter().map(|e| Event { source, ..e }));
156                day.events.sort_by_key(|e| (e.at, e.source, e.id));
157            }
158        }
159        merged.tracked = merged.days.iter().map(|d| d.tracked).sum();
160        merged
161    }
162
163    /// The ids of the entries shown, each once, in time order.
164    pub fn ids(&self) -> Vec<u32> {
165        self.entries().into_iter().map(|(_, id)| id).collect()
166    }
167
168    /// The entries shown as `(source, id)`, each once, in time order; see [`Calendar::merge`].
169    pub fn entries(&self) -> Vec<(usize, u32)> {
170        let mut entries = Vec::new();
171        for event in self.days.iter().flat_map(|d| &d.events) {
172            if !entries.contains(&(event.source, event.id)) {
173                entries.push((event.source, event.id));
174            }
175        }
176        entries
177    }
178}
179
180impl Brain {
181    /// Computes the calendar for `view`: each day's interactions and time worked.
182    pub fn calendar(&self, view: &CalendarView) -> Calendar {
183        let now = now();
184        let (first, last) = grid(view.period);
185        let tasks: Vec<&Task> = self
186            .tasks
187            .iter()
188            .filter(|t| view.tag.as_ref().is_none_or(|tag| t.tags.contains(tag)) && t.matches(&view.query))
189            .collect();
190        let days: Vec<Day> = first
191            .iter_days()
192            .take_while(|&date| date <= last)
193            .map(|date| {
194                let day = Period::containing(Unit::Day, date.and_time(NaiveTime::MIN));
195                let in_period = view.period.contains(day.start) || day.contains(view.period.start);
196                if !in_period {
197                    return Day { date, in_period, tracked: Duration::zero(), events: Vec::new(), worked: Vec::new() };
198                }
199                let (from, to) = day.bounds();
200                let worked: Vec<_> = tasks.iter().flat_map(|t| &t.sessions).map(|s| s.span(from, to, now)).collect();
201                let tracked = covered(worked.iter().copied());
202                let mut events: Vec<Event> =
203                    tasks.iter().filter_map(|task| summary(task, from, to, now, view.show)).collect();
204                events.sort_by_key(|e| (e.at, e.id));
205                Day { date, in_period, tracked, events, worked }
206            })
207            .collect();
208        let tracked = days.iter().map(|d| d.tracked).sum();
209        Calendar { days, tracked }
210    }
211}
212
213/// The first and last dates the calendar shows for `period`: Monday to Sunday around a month,
214/// otherwise its own days.
215fn grid(period: Period) -> (NaiveDate, NaiveDate) {
216    let first = period.start.date();
217    let last = (period.end() - Duration::seconds(1)).date();
218    if period.unit == Unit::Month {
219        let monday = Period::containing(Unit::Week, period.start).start.date();
220        let sunday = Period::containing(Unit::Week, last.and_time(NaiveTime::MIN)).end().date() - Duration::days(1);
221        (monday, sunday)
222    } else {
223        (first, last)
224    }
225}
226
227/// `task`'s day in `[from, to)` as one event: its last status change, else its creation, else its
228/// last comment, else its last session; with the time worked and the comments of that day. `None`
229/// when nothing `show` keeps happened to it.
230fn summary(task: &Task, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>, show: Show) -> Option<Event> {
231    let mut all: Vec<Event> = interactions(task, from, to, now).into_iter().filter(|e| e.what.shown_by(show)).collect();
232    all.sort_by_key(|e| e.at);
233    let worked = all
234        .iter()
235        .map(|e| match e.what {
236            What::Worked { end } => end.unwrap_or(now) - e.at,
237            _ => Duration::zero(),
238        })
239        .sum();
240    let comments = all.iter().filter(|e| matches!(e.what, What::Commented { .. })).count();
241    let last = |pick: fn(&What) -> bool| all.iter().rev().find(|e| pick(&e.what));
242    let main = last(|w| matches!(w, What::Moved { .. }))
243        .or_else(|| last(|w| matches!(w, What::Created)))
244        .or_else(|| last(|w| matches!(w, What::Commented { .. })))
245        .or_else(|| all.last())?;
246    Some(Event { worked, comments, ..main.clone() })
247}
248
249/// Everything that happened to `task` in `[from, to)`.
250fn interactions(task: &Task, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>) -> Vec<Event> {
251    let event = |at, what| Event {
252        at,
253        id: task.id,
254        title: task.title.clone(),
255        kind: task.kind,
256        tags: task.tags.clone(),
257        what,
258        source: 0,
259        worked: Duration::zero(),
260        comments: 0,
261    };
262    let within = |at: DateTime<Local>| from <= at && at < to;
263    let mut events = Vec::new();
264    if within(task.created) {
265        events.push(event(task.created, What::Created));
266    }
267    // Moves before sessions, so at the same minute "→ doing" reads before the work it started; the
268    // sort by time that follows is stable.
269    for change in task.history.iter().filter(|c| within(c.at)) {
270        events.push(event(change.at, What::Moved { from: change.from, to: change.to }));
271    }
272    for session in &task.sessions {
273        if session.overlap(from, to, now).is_zero() {
274            continue;
275        }
276        // A session still running past the end of this day ends there; one running now stays open.
277        let end = match session.end {
278            Some(end) => Some(end.min(to)),
279            None if now >= to => Some(to),
280            None => None,
281        };
282        events.push(event(session.start.max(from), What::Worked { end }));
283    }
284    for comment in task.comments.iter().filter(|c| within(c.created)) {
285        let excerpt = comment.body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or_default().to_string();
286        events.push(event(comment.created, What::Commented { excerpt }));
287    }
288    events
289}