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, 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.
121    pub tracked: Duration,
122    /// One summary per entry with interactions shown, in time order.
123    pub events: Vec<Event>,
124}
125
126/// A computed calendar: whole weeks for a month, otherwise the period's days.
127#[derive(Debug)]
128pub struct Calendar {
129    /// Every day of the grid, in order.
130    pub days: Vec<Day>,
131    /// Time worked in the period.
132    pub tracked: Duration,
133}
134
135impl Calendar {
136    /// How many interactions are shown.
137    pub fn events(&self) -> usize {
138        self.days.iter().map(|d| d.events.len()).sum()
139    }
140
141    /// Several brains' calendars for the same view as one: each event says which it came from, as
142    /// its calendar's index in `parts`.
143    pub fn merge(parts: Vec<Calendar>) -> Calendar {
144        let mut parts = parts.into_iter().enumerate();
145        let Some((_, mut merged)) = parts.next() else {
146            return Calendar { days: Vec::new(), tracked: Duration::zero() };
147        };
148        for (source, calendar) in parts {
149            merged.tracked += calendar.tracked;
150            for (day, other) in merged.days.iter_mut().zip(calendar.days) {
151                day.tracked += other.tracked;
152                day.events.extend(other.events.into_iter().map(|e| Event { source, ..e }));
153                day.events.sort_by_key(|e| (e.at, e.source, e.id));
154            }
155        }
156        merged
157    }
158
159    /// The ids of the entries shown, each once, in time order.
160    pub fn ids(&self) -> Vec<u32> {
161        self.entries().into_iter().map(|(_, id)| id).collect()
162    }
163
164    /// The entries shown as `(source, id)`, each once, in time order; see [`Calendar::merge`].
165    pub fn entries(&self) -> Vec<(usize, u32)> {
166        let mut entries = Vec::new();
167        for event in self.days.iter().flat_map(|d| &d.events) {
168            if !entries.contains(&(event.source, event.id)) {
169                entries.push((event.source, event.id));
170            }
171        }
172        entries
173    }
174}
175
176impl Brain {
177    /// Computes the calendar for `view`: each day's interactions and time worked.
178    pub fn calendar(&self, view: &CalendarView) -> Calendar {
179        let now = now();
180        let (first, last) = grid(view.period);
181        let tasks: Vec<&Task> = self
182            .tasks
183            .iter()
184            .filter(|t| view.tag.as_ref().is_none_or(|tag| t.tags.contains(tag)) && t.matches(&view.query))
185            .collect();
186        let days: Vec<Day> = first
187            .iter_days()
188            .take_while(|&date| date <= last)
189            .map(|date| {
190                let day = Period::containing(Unit::Day, date.and_time(NaiveTime::MIN));
191                let in_period = view.period.contains(day.start) || day.contains(view.period.start);
192                if !in_period {
193                    return Day { date, in_period, tracked: Duration::zero(), events: Vec::new() };
194                }
195                let (from, to) = day.bounds();
196                let tracked = tasks.iter().map(|t| t.time_between(from, to, now)).sum();
197                let mut events: Vec<Event> =
198                    tasks.iter().filter_map(|task| summary(task, from, to, now, view.show)).collect();
199                events.sort_by_key(|e| (e.at, e.id));
200                Day { date, in_period, tracked, events }
201            })
202            .collect();
203        let tracked = days.iter().map(|d| d.tracked).sum();
204        Calendar { days, tracked }
205    }
206}
207
208/// The first and last dates the calendar shows for `period`: Monday to Sunday around a month,
209/// otherwise its own days.
210fn grid(period: Period) -> (NaiveDate, NaiveDate) {
211    let first = period.start.date();
212    let last = (period.end() - Duration::seconds(1)).date();
213    if period.unit == Unit::Month {
214        let monday = Period::containing(Unit::Week, period.start).start.date();
215        let sunday = Period::containing(Unit::Week, last.and_time(NaiveTime::MIN)).end().date() - Duration::days(1);
216        (monday, sunday)
217    } else {
218        (first, last)
219    }
220}
221
222/// `task`'s day in `[from, to)` as one event: its last status change, else its creation, else its
223/// last comment, else its last session; with the time worked and the comments of that day. `None`
224/// when nothing `show` keeps happened to it.
225fn summary(task: &Task, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>, show: Show) -> Option<Event> {
226    let mut all: Vec<Event> = interactions(task, from, to, now).into_iter().filter(|e| e.what.shown_by(show)).collect();
227    all.sort_by_key(|e| e.at);
228    let worked = all
229        .iter()
230        .map(|e| match e.what {
231            What::Worked { end } => end.unwrap_or(now) - e.at,
232            _ => Duration::zero(),
233        })
234        .sum();
235    let comments = all.iter().filter(|e| matches!(e.what, What::Commented { .. })).count();
236    let last = |pick: fn(&What) -> bool| all.iter().rev().find(|e| pick(&e.what));
237    let main = last(|w| matches!(w, What::Moved { .. }))
238        .or_else(|| last(|w| matches!(w, What::Created)))
239        .or_else(|| last(|w| matches!(w, What::Commented { .. })))
240        .or_else(|| all.last())?;
241    Some(Event { worked, comments, ..main.clone() })
242}
243
244/// Everything that happened to `task` in `[from, to)`.
245fn interactions(task: &Task, from: DateTime<Local>, to: DateTime<Local>, now: DateTime<Local>) -> Vec<Event> {
246    let event = |at, what| Event {
247        at,
248        id: task.id,
249        title: task.title.clone(),
250        kind: task.kind,
251        tags: task.tags.clone(),
252        what,
253        source: 0,
254        worked: Duration::zero(),
255        comments: 0,
256    };
257    let within = |at: DateTime<Local>| from <= at && at < to;
258    let mut events = Vec::new();
259    if within(task.created) {
260        events.push(event(task.created, What::Created));
261    }
262    // Moves before sessions, so at the same minute "→ doing" reads before the work it started; the
263    // sort by time that follows is stable.
264    for change in task.history.iter().filter(|c| within(c.at)) {
265        events.push(event(change.at, What::Moved { from: change.from, to: change.to }));
266    }
267    for session in &task.sessions {
268        if session.overlap(from, to, now).is_zero() {
269            continue;
270        }
271        // A session still running past the end of this day ends there; one running now stays open.
272        let end = match session.end {
273            Some(end) => Some(end.min(to)),
274            None if now >= to => Some(to),
275            None => None,
276        };
277        events.push(event(session.start.max(from), What::Worked { end }));
278    }
279    for comment in task.comments.iter().filter(|c| within(c.created)) {
280        let excerpt = comment.body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or_default().to_string();
281        events.push(event(comment.created, What::Commented { excerpt }));
282    }
283    events
284}