timelog/
day.rs

1//! Represention of a day as a set of times, entries, and durations.
2//!
3//! # Examples
4//!
5//! ```rust, no_run
6//! use timelog::{Day, Entry, Result};
7//!
8//! # fn main() -> Result<()> {
9//! # let entries: Vec<Entry> = vec![];
10//! # let mut entry_iter = entries.into_iter();
11//! let mut day = Day::new("2021-07-02")?;
12//! while let Some(entry) = entry_iter.next() {
13//!     day.add_entry(entry);
14//! }
15//! day.finish()?;
16//! print!("{}", day.detail_report());
17//! #   Ok(())
18//! #  }
19//! ```
20//!
21//! # Description
22//!
23//! The [`Day`] type represents the entries of a particular day. It tracks projects and combines
24//! time spent on the same task from multiple points in the day.
25//!
26//! [`Day`] also provides the ability to print various reports on the day's
27//! activities.
28
29use std::collections::HashMap;
30use std::fmt;
31use std::time::Duration;
32
33use regex::Regex;
34
35#[doc(inline)]
36use crate::chart::{Percentages, PieData};
37#[cfg(doc)]
38use crate::date;
39#[doc(inline)]
40use crate::date::{Date, DateTime};
41#[doc(inline)]
42use crate::entry::Entry;
43#[doc(inline)]
44use crate::error::Error;
45use crate::TaskEvent;
46
47pub mod report;
48
49/// Representation of chart report about a [`Day`].
50pub use report::DailyChart;
51/// Representation of the full report about a [`Day`].
52pub use report::DetailReport;
53/// Representation of the event report about a [`Day`].
54pub use report::EventReport;
55/// Representation of the hours report about a [`Day`].
56pub use report::HoursReport;
57/// Representation of the summary report about a [`Day`].
58pub use report::SummaryReport;
59
60/// Type representing a day and all associated tasks
61#[derive(Debug)]
62pub struct Day {
63    /// [`Date`] for today
64    stamp:      Date,
65    /// Optional timestamp representing the start of entries for the day
66    start:      Option<DateTime>,
67    /// Total [`Duration`] of all of the entries for the day
68    dur:        Duration,
69    /// Map of tasks to task entries
70    tasks:      HashMap<String, TaskEvent>,
71    /// Map of projects to task entries
72    proj_dur:   HashMap<String, Duration>,
73    /// List of task entries
74    entries:    Vec<TaskEvent>,
75    /// List of zero duration event entries
76    events:     Vec<Entry>,
77    /// Optional start of most recent entry while parsing a day
78    last_start: Option<DateTime>,
79    /// Optional most recent entry
80    last_entry: Option<Entry>
81}
82
83/// Format duration information as a [`String`] of the form `H:MM`
84#[rustfmt::skip]
85pub fn format_dur(dur: &Duration) -> String {
86    const DAY: u64 = 24 * 60 * 60;
87    const HOUR: u64 = 3600;
88    const MINUTE: u64 = 60;
89
90    let secs = dur.as_secs() + 30; // force rounding
91    if secs > DAY {
92        format!("{}d {:>2}:{:0>2}", secs / DAY, (secs % DAY) / HOUR, (secs % HOUR) / MINUTE)
93    }
94    else {
95        format!("{:>2}:{:0>2}", (secs / HOUR), (secs % HOUR) / MINUTE)
96    }
97}
98
99impl<'a> Day {
100    /// Creates a [`Day`] struct that collects the entries for the date specified by
101    /// the `stamp`.
102    ///
103    /// # Errors
104    ///
105    /// - Return an [`Error::MissingDate`] if the string is empty.
106    /// - Return an [`InvalidDate`](date::DateError::InvalidDate) error if the `stamp` is not
107    /// formatted as 'YYYY-MM-DD'.
108    pub fn new(stamp: &str) -> crate::Result<Self> {
109        if stamp.is_empty() {
110            return Err(Error::MissingDate);
111        }
112        Ok(Day {
113            stamp:      Date::try_from(stamp)?,
114            start:      None,
115            dur:        Duration::default(),
116            tasks:      HashMap::new(),
117            proj_dur:   HashMap::new(),
118            entries:    Vec::new(),
119            events:     Vec::new(),
120            last_start: None,
121            last_entry: None
122        })
123    }
124
125    /// Return the duration of the day in seconds
126    pub fn duration_secs(&self) -> u64 { self.dur.as_secs() }
127
128    /// Returns `true` only if no entries or events have been added to the day.
129    pub fn is_empty(&self) -> bool { self.entries.is_empty() && self.events.is_empty() }
130
131    /// Returns `true` only the day is complete.
132    pub fn is_complete(&self) -> bool { self.last_start.is_none() }
133
134    /// Return the date stamp for the day in 'YYYY-MM-DD' form.
135    pub fn date_stamp(&self) -> String { self.stamp.into() }
136
137    /// Return the date for the [`Day`] object in a [`Date`].
138    pub fn date(&self) -> Date { self.stamp }
139
140    /// Return an iterator over the project names for today
141    pub fn projects(&self) -> impl Iterator<Item = &'_ str> {
142        self.proj_dur.keys().map(String::as_str)
143    }
144
145    /// Return an iterator over the events for today.
146    pub fn events(&self) -> impl Iterator<Item = &'_ Entry> { self.events.iter() }
147
148    // Update the task duration for most recent [`Entry`]
149    fn update_task_duration(&mut self, prev: &Entry, dur: &Duration) {
150        if let Some(task) = self.tasks.get_mut(prev.entry_text()) {
151            task.add_dur(*dur);
152        }
153        else {
154            let proj = prev.project();
155            let task = TaskEvent::new(prev.date_time(), proj, *dur);
156            self.tasks.insert(prev.entry_text().to_string(), task);
157        }
158    }
159
160    // Update the project duration for most recent entry
161    #[rustfmt::skip]
162    fn update_project_duration(&mut self, proj: &str, dur: &Duration) {
163        match self.proj_dur.get_mut(proj) {
164            Some(proj_dur) => { *proj_dur += *dur; },
165            None => { self.proj_dur.insert(proj.to_string(), *dur); },
166        }
167    }
168
169    /// Add an [`Entry`] to the current [`Day`].
170    ///
171    /// # Errors
172    ///
173    /// - Return an [`EntryOrder`](date::DateError::EntryOrder) error if the new entry is before the
174    /// previous entry.
175    pub fn add_entry(&mut self, entry: Entry) -> crate::Result<()> {
176        if entry.is_event() {
177            self.events.push(entry);
178        }
179        else if !entry.is_ignore() {
180            self.update_dur(&entry.date_time())?;
181            self.start_task(&entry);
182            self.last_entry = (!entry.is_stop()).then_some(entry);
183        }
184        Ok(())
185    }
186
187    /// Update the duration of the most recent task
188    ///
189    /// # Errors
190    ///
191    /// - Return an [`EntryOrder`](date::DateError::EntryOrder) error if the new entry is before the
192    /// previous entry.
193    pub fn update_dur(&mut self, date_time: &DateTime) -> crate::Result<()> {
194        if let Some(prev) = &self.last_entry.clone() {
195            let curr_dur = (*date_time - prev.date_time())?;
196            if !prev.entry_text().is_empty() {
197                self.update_task_duration(prev, &curr_dur);
198            }
199            let prev_proj = prev.project().unwrap_or_default();
200            self.update_project_duration(prev_proj, &curr_dur);
201            self.dur += curr_dur;
202            if let Some(prev) = self.entries.last_mut() {
203                prev.add_dur(curr_dur);
204            }
205        }
206        Ok(())
207    }
208
209    /// Update the duration of the most recent task
210    ///
211    /// # Errors
212    ///
213    /// - Return an [`EntryOrder`](date::DateError::EntryOrder) error if the new entry is before the
214    /// previous entry.
215    pub fn finish(&mut self) -> crate::Result<()> {
216        if !self.is_complete() {
217            let date = (self.date() == Date::today())
218                .then(DateTime::now)
219                .unwrap_or_else(|| self.stamp.day_end());
220            self.update_dur(&date)?;
221            self.last_start = None;
222        }
223
224        Ok(())
225    }
226
227    /// Start a day from previous day's last entry.
228    ///
229    /// # Errors
230    ///
231    /// - Return an [`EntryOrder`](date::DateError::EntryOrder) error if the new entry is before the
232    /// previous entry.
233    pub fn start_day(&mut self, entry: &Entry) -> crate::Result<()> {
234        if entry.is_start() {
235            let stamp = entry.date_time();
236            self.add_entry(entry.clone())?;
237            self.last_start = Some(stamp);
238        }
239        Ok(())
240    }
241
242    /// Initialize a new task item in the day based on the [`Entry`] object supplied in `event`.
243    ///
244    /// This method only starts a task if no previous matching task exists in the day.
245    pub fn start_task(&mut self, entry: &Entry) {
246        if entry.is_stop() {
247            self.last_start = None;
248            return;
249        }
250        let task = entry.entry_text();
251        self.last_start = Some(entry.date_time());
252        self.tasks
253            .entry(task.to_string())
254            .or_insert_with(|| TaskEvent::from_entry(entry));
255        self.entries.push(TaskEvent::from_entry(entry));
256    }
257
258    // Format the stamp line to f with the supplied `separator`.
259    fn _format_stamp_line(&self, f: &mut fmt::Formatter<'_>, sep: &str) -> fmt::Result {
260        writeln!(f, "{}{sep} {}", self.date_stamp(), format_dur(&self.dur))
261    }
262
263    // Format the project line to `f` with the supplied `separator`.
264    fn _format_project_line(
265        &self, f: &mut fmt::Formatter<'_>, proj: &str, dur: &Duration
266    ) -> fmt::Result {
267        writeln!(f, "  {proj:<13} {}", format_dur(dur))
268    }
269
270    // Format the task line to `f` with the supplied `separator`.
271    fn _format_task_line(
272        &self, f: &mut fmt::Formatter<'_>, task: &str, dur: &Duration
273    ) -> fmt::Result {
274        let fdur = format_dur(dur);
275        match Entry::task_breakdown(task) {
276            (Some(task), Some(detail)) => {
277                writeln!(f, "    {task:<19} {fdur} ({detail})")
278            }
279            (Some(task), None) => writeln!(f, "    {task:<19} {fdur}"),
280            (None, Some(detail)) => writeln!(f, "    {detail:<19} {fdur}"),
281            _ => writeln!(f, "    {:<19} {fdur}", "")
282        }
283    }
284
285    /// Return a [`DetailReport`] from the current [`Day`].
286    pub fn detail_report(&'a self) -> DetailReport<'a> { DetailReport::new(self) }
287
288    /// Return a [`SummaryReport`] from the current [`Day`].
289    pub fn summary_report(&'a self) -> SummaryReport<'a> { SummaryReport::new(self) }
290
291    /// Return a [`HoursReport`] from the current [`Day`].
292    pub fn hours_report(&'a self) -> HoursReport<'a> { HoursReport::new(self) }
293
294    /// Return a [`EventReport`] from the current [`Day`].
295    pub fn event_report(&'a self, compact: bool) -> EventReport<'a> {
296        EventReport::new(self, compact)
297    }
298
299    /// Return a [`DailyChart`] from the current [`Day`].
300    pub fn daily_chart(&'a self) -> DailyChart<'a> { DailyChart::new(self) }
301
302    /// Return `true` if the day contains one or more tasks.
303    pub fn has_tasks(&self) -> bool { !self.tasks.is_empty() }
304
305    /// Return `true` if the day contains one or more events.
306    pub fn has_events(&self) -> bool { !self.events.is_empty() }
307
308    // Filter tasks by project, returning a HashMap.
309    fn project_filtered_tasks(&self, filter: &Regex) -> HashMap<String, TaskEvent> {
310        self.tasks
311            .iter()
312            .filter(|(_, t)| filter.is_match(&t.project()))
313            .fold(HashMap::new(), |mut h, (k, t)| {
314                h.insert(k.to_string(), t.clone());
315                h
316            })
317    }
318
319    // Filter events by project, returning a HashMap.
320    fn project_filtered_events(&self, filter: &Regex) -> Vec<Entry> {
321        self.events
322            .iter()
323            .filter(|e| filter.is_match(e.project().unwrap_or_default()))
324            .cloned()
325            .collect()
326    }
327
328    // Return a [`HashMap`] of projects and [`Duration`]s that match the supplied [`Regex`]es.
329    fn project_filtered_durs(&self, filter: &Regex) -> HashMap<String, Duration> {
330        self.proj_dur
331            .iter()
332            .filter(|(k, _)| filter.is_match(k))
333            .fold(HashMap::new(), |mut h, (k, v)| {
334                h.insert(k.to_string(), *v);
335                h
336            })
337    }
338
339    /// Make a copy of the current [`Day`] object containing only the tasks associated
340    /// with a supplied [`Regex`].
341    #[must_use]
342    pub fn filtered_by_project(&self, filter: &Regex) -> Self {
343        let proj_durs = self.project_filtered_durs(filter);
344        Self {
345            stamp:      self.stamp,
346            start:      self.start,
347            dur:        proj_durs.values().sum(),
348            tasks:      self.project_filtered_tasks(filter),
349            entries:    self.entries.clone(), // TODO: Need to filter somehow
350            events:     self.project_filtered_events(filter),
351            proj_dur:   proj_durs,
352            last_start: self.start,
353            last_entry: None
354        }
355    }
356
357    /// Return a [`Vec`] of tuples mapping project name to percentage of the overall
358    /// time this project took.
359    pub fn project_percentages(&'a self) -> Percentages {
360        let mut pie = PieData::default();
361
362        self.proj_dur
363            .iter()
364            .for_each(|(proj, dur)| pie.add_secs(proj.as_str(), dur.as_secs()));
365
366        pie.percentages()
367    }
368
369    /// Return a [`Vec`] of tuples mapping the task name and percentage of the
370    /// supplied project.
371    pub fn task_percentages(&self, proj: &str) -> Percentages {
372        let mut pie = PieData::default();
373
374        self.tasks
375            .iter()
376            .filter(|(_t, tsk)| tsk.project() == proj)
377            .for_each(|(t, tsk)| {
378                let task = match Entry::task_breakdown(t) {
379                    (None, None) => String::new(),
380                    (Some(tname), None) => tname,
381                    (None, Some(detail)) => format!(" ({detail})"),
382                    (Some(tname), Some(detail)) => format!("{tname} ({detail})")
383                };
384                pie.add_secs(&task, tsk.as_secs());
385            });
386
387        pie.percentages()
388    }
389
390    // Return an iterator over the [`TaskEvents`] in the day.
391    fn entries(&self) -> impl Iterator<Item = &'_ TaskEvent> { self.entries.iter() }
392}
393
394#[cfg(test)]
395pub(crate) mod tests {
396    use spectral::prelude::*;
397
398    use super::*;
399    use crate::chart::TagPercent;
400    use crate::date::DateError;
401    use crate::entry::{Entry, EntryKind};
402
403    const INITIAL_ENTRIES: [(&str, u64); 8] = [
404        ("+proj1 @Make changes", 0),
405        ("+proj2 @Start work", 1),
406        ("+proj1 @Make changes", 2),
407        ("+proj1 @Stuff Other changes", 3),
408        ("stop", 4),
409        ("+proj1 @Stuff Other changes", 4),
410        ("+proj1 @Final", 5),
411        ("stop", 6)
412    ];
413    #[rustfmt::skip]
414    const SOME_EVENTS: [(&str, u64); 2] = [
415        ("+foo thing1", 30),
416        ("+foo thing2", 30 + 5)
417    ];
418    const MORE_ENTRIES: [(&str, u64); 4] = [
419        ("+proj3 @Phone call", 60 + 0),
420        ("+proj4 @Research", 60 + 1),
421        ("@Phone call", 60 + 2),
422        ("stop", 60 + 4)
423    ];
424
425    #[test]
426    #[rustfmt::skip]
427    fn test_new_empty_stamp() {
428        assert_that!(Day::new("")).is_err_containing(Error::MissingDate);
429    }
430
431    #[test]
432    fn test_new_invalid_stamp() {
433        assert_that!(Day::new("foo")).is_err_containing(&(DateError::InvalidDate).into());
434    }
435
436    #[test]
437    fn test_update_dur() {
438        let day_result = Day::new("2021-06-10");
439        assert_that!(&day_result).is_ok();
440
441        let mut day = day_result.unwrap();
442        let _ = day.update_dur(&DateTime::new((2021, 6, 10), (8, 0, 0)).unwrap());
443        assert_that!(day.duration_secs()).is_equal_to(&0);
444    }
445
446    #[test]
447    fn test_add_entry() {
448        let day_result = Day::new("2021-06-10");
449        assert_that!(&day_result).is_ok();
450
451        let mut day = day_result.unwrap();
452        let entry = Entry::from_line("2021-06-10 08:00:00 +proj1 do something").unwrap();
453        let _ = day.add_entry(entry);
454        let entry = Entry::from_line("2021-06-10 08:45:00 stop").unwrap();
455        let _ = day.add_entry(entry);
456        assert_that!(day.duration_secs()).is_equal_to(&(45 * 60));
457    }
458
459    #[test]
460    fn test_format_dur() {
461        assert_that!(format_dur(&Duration::default())).is_equal_to(&String::from(" 0:00"));
462        assert_that!(format_dur(&Duration::from_secs(3600))).is_equal_to(&String::from(" 1:00"));
463        assert_that!(format_dur(&Duration::from_secs(3629))).is_equal_to(&String::from(" 1:00"));
464        assert_that!(format_dur(&Duration::from_secs(3630))).is_equal_to(&String::from(" 1:01"));
465        assert_that!(format_dur(&Duration::from_secs(3660))).is_equal_to(&String::from(" 1:01"));
466        assert_that!(format_dur(&Duration::from_secs(36000))).is_equal_to(&String::from("10:00"));
467        assert_that!(format_dur(&Duration::from_secs(360000)))
468            .is_equal_to(&String::from("4d  4:00"));
469        assert_that!(format_dur(&Duration::from_secs(300000)))
470            .is_equal_to(&String::from("3d 11:20"));
471    }
472
473    #[test]
474    fn test_new_empty() {
475        let day_result = Day::new("2021-06-10");
476        assert_that!(&day_result).is_ok();
477
478        let day = day_result.unwrap();
479        assert_that!(&day.is_empty()).is_true();
480        assert_that!(&day.duration_secs()).is_equal_to(0u64);
481        assert_that!(&day.is_complete()).is_true();
482        assert_that!(&day.date_stamp()).is_equal_to(&String::from("2021-06-10"));
483    }
484
485    pub fn add_entries(day: &mut Day) -> crate::Result<()> {
486        add_some_entries(
487            day,
488            DateTime::new((2021, 6, 10), (8, 0, 0)).unwrap(),
489            INITIAL_ENTRIES.iter()
490        )?;
491        day.finish()?;
492        Ok(())
493    }
494
495    pub fn add_some_events(day: &mut Day) -> crate::Result<()> {
496        let stamp = DateTime::new((2021, 6, 10), (8, 0, 0)).unwrap();
497        for (entry, mins) in SOME_EVENTS.iter() {
498            let ev = Entry::new_marked(
499                entry,
500                (stamp + DateTime::minutes(*mins)).unwrap(),
501                EntryKind::Event
502            );
503            day.add_entry(ev)?;
504        }
505        day.finish()?;
506        Ok(())
507    }
508
509    pub fn add_extra_entries(day: &mut Day) -> crate::Result<()> {
510        add_some_entries(
511            day,
512            DateTime::new((2021, 6, 10), (8, 0, 0)).unwrap(),
513            INITIAL_ENTRIES.iter().chain(MORE_ENTRIES.iter())
514        )?;
515        day.finish()?;
516        Ok(())
517    }
518
519    fn add_some_entries<'b, I>(day: &mut Day, stamp: DateTime, entries: I) -> crate::Result<()>
520    where
521        I: Iterator<Item = &'b (&'b str, u64)>
522    {
523        for (entry, mins) in entries {
524            let ev = Entry::new(entry, (stamp + DateTime::minutes(*mins)).unwrap());
525            day.add_entry(ev)?;
526        }
527        Ok(())
528    }
529
530    #[test]
531    fn test_task_percentages() {
532        let day_result = Day::new("2021-06-10");
533        assert_that!(&day_result).is_ok();
534
535        let mut day = day_result.unwrap();
536        add_extra_entries(&mut day).expect("Entries out of order");
537
538        let expect: Percentages = vec![
539            TagPercent::new("Make (changes)", 40.0).unwrap(),
540            TagPercent::new("Stuff (Other changes)", 40.0).unwrap(),
541            TagPercent::new("Final", 20.0).unwrap(),
542        ];
543        let mut actual = day.task_percentages("proj1");
544        actual.sort_by(|lhs, rhs| lhs.partial_cmp(rhs).unwrap());
545        assert_that!(actual).is_equal_to(expect);
546    }
547
548    #[test]
549    fn test_entries() {
550        let day_result = Day::new("2021-06-10");
551        assert_that!(&day_result).is_ok();
552
553        let mut day = day_result.unwrap();
554        add_extra_entries(&mut day).expect("Entries out of order");
555
556        assert_that!(day.entries().count()).is_equal_to(9);
557        #[rustfmt::skip]
558        let expected = [
559            (DateTime::new((2021, 6, 10), (8, 0, 0)).unwrap(), "proj1",  60),
560            (DateTime::new((2021, 6, 10), (8, 1, 0)).unwrap(), "proj2",  60),
561            (DateTime::new((2021, 6, 10), (8, 2, 0)).unwrap(), "proj1",  60),
562            (DateTime::new((2021, 6, 10), (8, 3, 0)).unwrap(), "proj1",  60),
563            (DateTime::new((2021, 6, 10), (8, 4, 0)).unwrap(), "proj1",  60),
564            (DateTime::new((2021, 6, 10), (8, 5, 0)).unwrap(), "proj1",  60),
565            (DateTime::new((2021, 6, 10), (9, 0, 0)).unwrap(), "proj3",  60),
566            (DateTime::new((2021, 6, 10), (9, 1, 0)).unwrap(), "proj4",  60),
567            (DateTime::new((2021, 6, 10), (9, 2, 0)).unwrap(), "", 120),
568        ];
569        for (ev, expect) in day.entries().zip(expected.iter()) {
570            assert_that!(ev.start()).is_equal_to(&expect.0);
571            assert_that!(ev.proj().unwrap_or_default()).is_equal_to(&expect.1.to_string());
572            assert_that!(ev.as_secs()).is_equal_to(expect.2);
573        }
574    }
575}