use std::collections::HashMap;
use std::fmt;
use std::time::Duration;
use regex::Regex;
#[doc(inline)]
use crate::chart::{Percentages, PieData};
#[cfg(doc)]
use crate::date;
#[doc(inline)]
use crate::date::{Date, DateTime};
#[doc(inline)]
use crate::entry::Entry;
#[doc(inline)]
use crate::error::Error;
use crate::TaskEvent;
pub mod report;
pub use report::DailyChart;
pub use report::DetailReport;
pub use report::EventReport;
pub use report::HoursReport;
pub use report::SummaryReport;
#[derive(Debug)]
pub struct Day {
stamp: Date,
start: Option<DateTime>,
dur: Duration,
tasks: HashMap<String, TaskEvent>,
proj_dur: HashMap<String, Duration>,
entries: Vec<TaskEvent>,
events: Vec<Entry>,
last_start: Option<DateTime>,
last_entry: Option<Entry>
}
#[rustfmt::skip]
pub fn format_dur(dur: &Duration) -> String {
const DAY: u64 = 24 * 60 * 60;
const HOUR: u64 = 3600;
const MINUTE: u64 = 60;
let secs = dur.as_secs() + 30; if secs > DAY {
format!("{}d {:>2}:{:0>2}", secs / DAY, (secs % DAY) / HOUR, (secs % HOUR) / MINUTE)
}
else {
format!("{:>2}:{:0>2}", (secs / HOUR), (secs % HOUR) / MINUTE)
}
}
impl<'a> Day {
pub fn new(stamp: &str) -> crate::Result<Self> {
if stamp.is_empty() {
return Err(Error::MissingDate);
}
Ok(Day {
stamp: stamp.parse()?,
start: None,
dur: Duration::default(),
tasks: HashMap::new(),
proj_dur: HashMap::new(),
entries: Vec::new(),
events: Vec::new(),
last_start: None,
last_entry: None
})
}
pub fn duration_secs(&self) -> u64 { self.dur.as_secs() }
pub fn is_empty(&self) -> bool { self.entries.is_empty() && self.events.is_empty() }
pub fn is_complete(&self) -> bool { self.last_start.is_none() }
pub fn date_stamp(&self) -> String { self.stamp.into() }
pub fn date(&self) -> Date { self.stamp }
pub fn projects(&self) -> impl Iterator<Item = &'_ str> {
self.proj_dur.keys().map(String::as_str)
}
pub fn events(&self) -> impl Iterator<Item = &'_ Entry> { self.events.iter() }
fn update_task_duration(&mut self, prev: &Entry, dur: &Duration) {
if let Some(task) = self.tasks.get_mut(prev.entry_text()) {
task.add_dur(*dur);
}
else {
let proj = prev.project();
let task = TaskEvent::new(prev.date_time(), proj, *dur);
self.tasks.insert(prev.entry_text().to_string(), task);
}
}
#[rustfmt::skip]
fn update_project_duration(&mut self, proj: &str, dur: &Duration) {
match self.proj_dur.get_mut(proj) {
Some(proj_dur) => { *proj_dur += *dur; },
None => { self.proj_dur.insert(proj.to_string(), *dur); },
}
}
pub fn add_entry(&mut self, entry: Entry) -> crate::Result<()> {
if entry.is_event() {
self.events.push(entry);
}
else if !entry.is_ignore() {
self.update_dur(&entry.date_time())?;
self.start_task(&entry);
self.last_entry = (!entry.is_stop()).then_some(entry);
}
Ok(())
}
pub fn update_dur(&mut self, date_time: &DateTime) -> crate::Result<()> {
if let Some(prev) = &self.last_entry.clone() {
let curr_dur = (*date_time - prev.date_time())?;
if !prev.entry_text().is_empty() {
self.update_task_duration(prev, &curr_dur);
}
let prev_proj = prev.project().unwrap_or_default();
self.update_project_duration(prev_proj, &curr_dur);
self.dur += curr_dur;
if let Some(prev) = self.entries.last_mut() {
prev.add_dur(curr_dur);
}
}
Ok(())
}
pub fn finish(&mut self) -> crate::Result<()> {
if !self.is_complete() {
let date = (self.date() == Date::today())
.then(DateTime::now)
.unwrap_or_else(|| self.stamp.day_end());
self.update_dur(&date)?;
self.last_start = None;
}
Ok(())
}
pub fn start_day(&mut self, entry: &Entry) -> crate::Result<()> {
if entry.is_start() {
let stamp = entry.date_time();
self.add_entry(entry.clone())?;
self.last_start = Some(stamp);
}
Ok(())
}
pub fn start_task(&mut self, entry: &Entry) {
if entry.is_stop() {
self.last_start = None;
return;
}
let task = entry.entry_text();
self.last_start = Some(entry.date_time());
self.tasks
.entry(task.to_string())
.or_insert_with(|| TaskEvent::from_entry(entry));
self.entries.push(TaskEvent::from_entry(entry));
}
fn _format_stamp_line(&self, f: &mut fmt::Formatter<'_>, sep: &str) -> fmt::Result {
writeln!(f, "{}{sep} {}", self.date_stamp(), format_dur(&self.dur))
}
fn _format_project_line(
&self, f: &mut fmt::Formatter<'_>, proj: &str, dur: &Duration
) -> fmt::Result {
writeln!(f, " {proj:<13} {}", format_dur(dur))
}
fn _format_task_line(
&self, f: &mut fmt::Formatter<'_>, task: &str, dur: &Duration
) -> fmt::Result {
let fdur = format_dur(dur);
match Entry::task_breakdown(task) {
(Some(task), Some(detail)) => {
writeln!(f, " {task:<19} {fdur} ({detail})")
}
(Some(task), None) => writeln!(f, " {task:<19} {fdur}"),
(None, Some(detail)) => writeln!(f, " {detail:<19} {fdur}"),
_ => writeln!(f, " {:<19} {fdur}", "")
}
}
pub fn detail_report(&'a self) -> DetailReport<'a> { DetailReport::new(self) }
pub fn summary_report(&'a self) -> SummaryReport<'a> { SummaryReport::new(self) }
pub fn hours_report(&'a self) -> HoursReport<'a> { HoursReport::new(self) }
pub fn event_report(&'a self, compact: bool) -> EventReport<'a> {
EventReport::new(self, compact)
}
pub fn daily_chart(&'a self) -> DailyChart<'a> { DailyChart::new(self) }
pub fn has_tasks(&self) -> bool { !self.tasks.is_empty() }
pub fn has_events(&self) -> bool { !self.events.is_empty() }
fn project_filtered_tasks(&self, filter: &Regex) -> HashMap<String, TaskEvent> {
self.tasks
.iter()
.filter(|(_, t)| filter.is_match(&t.project()))
.fold(HashMap::new(), |mut h, (k, t)| {
h.insert(k.to_string(), t.clone());
h
})
}
fn project_filtered_events(&self, filter: &Regex) -> Vec<Entry> {
self.events
.iter()
.filter(|e| filter.is_match(e.project().unwrap_or_default()))
.cloned()
.collect()
}
fn project_filtered_durs(&self, filter: &Regex) -> HashMap<String, Duration> {
self.proj_dur
.iter()
.filter(|(k, _)| filter.is_match(k))
.fold(HashMap::new(), |mut h, (k, v)| {
h.insert(k.to_string(), *v);
h
})
}
#[must_use]
pub fn filtered_by_project(&self, filter: &Regex) -> Self {
let proj_durs = self.project_filtered_durs(filter);
Self {
stamp: self.stamp,
start: self.start,
dur: proj_durs.values().sum(),
tasks: self.project_filtered_tasks(filter),
entries: self.entries.clone(), events: self.project_filtered_events(filter),
proj_dur: proj_durs,
last_start: self.start,
last_entry: None
}
}
pub fn project_percentages(&'a self) -> Percentages {
let mut pie = PieData::default();
self.proj_dur
.iter()
.for_each(|(proj, dur)| pie.add_secs(proj.as_str(), dur.as_secs()));
pie.percentages()
}
pub fn task_percentages(&self, proj: &str) -> Percentages {
let mut pie = PieData::default();
self.tasks
.iter()
.filter(|(_t, tsk)| tsk.project() == proj)
.for_each(|(t, tsk)| {
let task = match Entry::task_breakdown(t) {
(None, None) => String::new(),
(Some(tname), None) => tname,
(None, Some(detail)) => format!(" ({detail})"),
(Some(tname), Some(detail)) => format!("{tname} ({detail})")
};
pie.add_secs(&task, tsk.as_secs());
});
pie.percentages()
}
fn entries(&self) -> impl Iterator<Item = &'_ TaskEvent> { self.entries.iter() }
}
#[cfg(test)]
pub(crate) mod tests {
use assert2::{assert, let_assert};
use rstest::rstest;
use super::*;
use crate::chart::TagPercent;
use crate::date::DateError;
use crate::entry::{Entry, EntryKind};
const INITIAL_ENTRIES: [(&str, u64); 8] = [
("+proj1 @Make changes", 0),
("+proj2 @Start work", 1),
("+proj1 @Make changes", 2),
("+proj1 @Stuff Other changes", 3),
("stop", 4),
("+proj1 @Stuff Other changes", 4),
("+proj1 @Final", 5),
("stop", 6)
];
#[rustfmt::skip]
const SOME_EVENTS: [(&str, u64); 2] = [
("+foo thing1", 30),
("+foo thing2", 30 + 5)
];
const MORE_ENTRIES: [(&str, u64); 4] = [
("+proj3 @Phone call", 60),
("+proj4 @Research", 60 + 1),
("@Phone call", 60 + 2),
("stop", 60 + 4)
];
#[test]
#[rustfmt::skip]
fn test_new_empty_stamp() {
let_assert!(Err(err) = Day::new(""));
assert!(err == Error::MissingDate);
}
#[test]
fn test_new_invalid_stamp() {
let_assert!(Err(err) = Day::new("foo"));
assert!(err == Error::from(DateError::InvalidDate));
}
#[test]
fn test_update_dur() {
let_assert!(Ok(mut day) = Day::new("2021-06-10"));
let_assert!(Ok(datetime) = DateTime::new((2021, 6, 10), (8, 0, 0)));
let_assert!(Ok(_) = day.update_dur(&datetime));
assert!(day.duration_secs() == 0);
}
#[test]
fn test_add_entry() {
let_assert!(Ok(mut day) = Day::new("2021-06-10"));
let_assert!(Ok(entry) = Entry::from_line("2021-06-10 08:00:00 +proj1 do something"));
let_assert!(Ok(_) = day.add_entry(entry));
let_assert!(Ok(entry) = Entry::from_line("2021-06-10 08:45:00 stop"));
let_assert!(Ok(_) = day.add_entry(entry));
assert!(day.duration_secs() == 45 * 60);
}
#[test]
fn test_format_dur_default() {
assert!(format_dur(&Duration::default()) == String::from(" 0:00"));
}
#[rstest]
#[case(3600, " 1:00")]
#[case(3629, " 1:00")]
#[case(3630, " 1:01")]
#[case(3660, " 1:01")]
#[case(36000, "10:00")]
#[case(360000, "4d 4:00")]
#[case(300000, "3d 11:20")]
fn test_format_dur(#[case]secs: u64, #[case]expected: &str) {
assert!(format_dur(&Duration::from_secs(secs)) == String::from(expected));
}
#[test]
fn test_new_empty() {
let_assert!(Ok(day) = Day::new("2021-06-10"));
assert!(day.is_empty());
assert!(day.duration_secs() == 0u64);
assert!(day.is_complete());
assert!(day.date_stamp() == String::from("2021-06-10"));
}
pub fn add_entries(day: &mut Day) -> crate::Result<()> {
let_assert!(Ok(datetime) = DateTime::new((2021, 6, 10), (8, 0, 0)));
add_some_entries(
day,
datetime,
INITIAL_ENTRIES.iter()
)?;
day.finish()?;
Ok(())
}
pub fn add_some_events(day: &mut Day) -> crate::Result<()> {
let_assert!(Ok(stamp) = DateTime::new((2021, 6, 10), (8, 0, 0)));
for (entry, mins) in SOME_EVENTS.iter() {
let_assert!(Ok(shifted_stamp) = stamp + DateTime::minutes(*mins));
let ev = Entry::new_marked(
entry,
shifted_stamp,
EntryKind::Event
);
day.add_entry(ev)?;
}
day.finish()?;
Ok(())
}
pub fn add_extra_entries(day: &mut Day) -> crate::Result<()> {
let_assert!(Ok(datetime) = DateTime::new((2021, 6, 10), (8, 0, 0)));
add_some_entries(
day,
datetime,
INITIAL_ENTRIES.iter().chain(MORE_ENTRIES.iter())
)?;
day.finish()?;
Ok(())
}
fn add_some_entries<'b, I>(day: &mut Day, stamp: DateTime, entries: I) -> crate::Result<()>
where
I: Iterator<Item = &'b (&'b str, u64)>
{
for (entry, mins) in entries {
let_assert!(Ok(shifted_stamp) = stamp + DateTime::minutes(*mins));
let ev = Entry::new(entry, shifted_stamp);
day.add_entry(ev)?;
}
Ok(())
}
#[test]
fn test_task_percentages() {
let_assert!(Ok(mut day) = Day::new("2021-06-10"));
let_assert!(Ok(_) = add_extra_entries(&mut day));
let expect: Percentages = vec![
TagPercent::new("Make (changes)", 40.0).expect("Hardcoded value"),
TagPercent::new("Stuff (Other changes)", 40.0).expect("Hardcoded value"),
TagPercent::new("Final", 20.0).expect("Hardcoded value"),
];
let mut actual = day.task_percentages("proj1");
actual.sort_by(|lhs, rhs| lhs.partial_cmp(rhs).expect("Hardcoded value"));
assert!(actual == expect);
}
#[test]
fn test_entries() {
let_assert!(Ok(mut day) = Day::new("2021-06-10"));
let_assert!(Ok(_) = add_extra_entries(&mut day));
assert!(day.entries().count() == 9);
#[rustfmt::skip]
let expected = [
(DateTime::new((2021, 6, 10), (8, 0, 0)).expect("Hardcoded value"), "proj1", 60),
(DateTime::new((2021, 6, 10), (8, 1, 0)).expect("Hardcoded value"), "proj2", 60),
(DateTime::new((2021, 6, 10), (8, 2, 0)).expect("Hardcoded value"), "proj1", 60),
(DateTime::new((2021, 6, 10), (8, 3, 0)).expect("Hardcoded value"), "proj1", 60),
(DateTime::new((2021, 6, 10), (8, 4, 0)).expect("Hardcoded value"), "proj1", 60),
(DateTime::new((2021, 6, 10), (8, 5, 0)).expect("Hardcoded value"), "proj1", 60),
(DateTime::new((2021, 6, 10), (9, 0, 0)).expect("Hardcoded value"), "proj3", 60),
(DateTime::new((2021, 6, 10), (9, 1, 0)).expect("Hardcoded value"), "proj4", 60),
(DateTime::new((2021, 6, 10), (9, 2, 0)).expect("Hardcoded value"), "", 120),
];
for (ev, expect) in day.entries().zip(expected.iter()) {
assert!(ev.start() == &expect.0);
assert!(ev.proj().unwrap_or_default() == expect.1.to_string());
assert!(ev.as_secs() == expect.2);
}
}
}