1use 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
49pub use report::DailyChart;
51pub use report::DetailReport;
53pub use report::EventReport;
55pub use report::HoursReport;
57pub use report::SummaryReport;
59
60#[derive(Debug)]
62pub struct Day {
63 stamp: Date,
65 start: Option<DateTime>,
67 dur: Duration,
69 tasks: HashMap<String, TaskEvent>,
71 proj_dur: HashMap<String, Duration>,
73 entries: Vec<TaskEvent>,
75 events: Vec<Entry>,
77 last_start: Option<DateTime>,
79 last_entry: Option<Entry>
81}
82
83#[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; 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 pub fn new(stamp: &str) -> crate::Result<Self> {
109 if stamp.is_empty() {
110 return Err(Error::MissingDate);
111 }
112 Ok(Day {
113 stamp: stamp.parse()?,
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 pub fn duration_secs(&self) -> u64 { self.dur.as_secs() }
127
128 pub fn is_empty(&self) -> bool { self.entries.is_empty() && self.events.is_empty() }
130
131 pub fn is_complete(&self) -> bool { self.last_start.is_none() }
133
134 pub fn date_stamp(&self) -> String { self.stamp.into() }
136
137 pub fn date(&self) -> Date { self.stamp }
139
140 pub fn projects(&self) -> impl Iterator<Item = &'_ str> {
142 self.proj_dur.keys().map(String::as_str)
143 }
144
145 pub fn events(&self) -> impl Iterator<Item = &'_ Entry> { self.events.iter() }
147
148 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 #[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 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 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 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 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 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 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 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 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 pub fn detail_report(&'a self) -> DetailReport<'a> { DetailReport::new(self) }
287
288 pub fn summary_report(&'a self) -> SummaryReport<'a> { SummaryReport::new(self) }
290
291 pub fn hours_report(&'a self) -> HoursReport<'a> { HoursReport::new(self) }
293
294 pub fn event_report(&'a self, compact: bool) -> EventReport<'a> {
296 EventReport::new(self, compact)
297 }
298
299 pub fn daily_chart(&'a self) -> DailyChart<'a> { DailyChart::new(self) }
301
302 pub fn has_tasks(&self) -> bool { !self.tasks.is_empty() }
304
305 pub fn has_events(&self) -> bool { !self.events.is_empty() }
307
308 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 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 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 #[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(), events: self.project_filtered_events(filter),
351 proj_dur: proj_durs,
352 last_start: self.start,
353 last_entry: None
354 }
355 }
356
357 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 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 fn entries(&self) -> impl Iterator<Item = &'_ TaskEvent> { self.entries.iter() }
392}
393
394#[cfg(test)]
395pub(crate) mod tests {
396 use assert2::{assert, let_assert};
397 use rstest::rstest;
398
399 use super::*;
400 use crate::chart::TagPercent;
401 use crate::date::DateError;
402 use crate::entry::{Entry, EntryKind};
403
404 const INITIAL_ENTRIES: [(&str, u64); 8] = [
405 ("+proj1 @Make changes", 0),
406 ("+proj2 @Start work", 1),
407 ("+proj1 @Make changes", 2),
408 ("+proj1 @Stuff Other changes", 3),
409 ("stop", 4),
410 ("+proj1 @Stuff Other changes", 4),
411 ("+proj1 @Final", 5),
412 ("stop", 6)
413 ];
414 #[rustfmt::skip]
415 const SOME_EVENTS: [(&str, u64); 2] = [
416 ("+foo thing1", 30),
417 ("+foo thing2", 30 + 5)
418 ];
419 const MORE_ENTRIES: [(&str, u64); 4] = [
420 ("+proj3 @Phone call", 60),
421 ("+proj4 @Research", 60 + 1),
422 ("@Phone call", 60 + 2),
423 ("stop", 60 + 4)
424 ];
425
426 #[test]
427 #[rustfmt::skip]
428 fn test_new_empty_stamp() {
429 let_assert!(Err(err) = Day::new(""));
430 assert!(err == Error::MissingDate);
431 }
432
433 #[test]
434 fn test_new_invalid_stamp() {
435 let_assert!(Err(err) = Day::new("foo"));
436 assert!(err == Error::from(DateError::InvalidDate));
437 }
438
439 #[test]
440 fn test_update_dur() {
441 let_assert!(Ok(mut day) = Day::new("2021-06-10"));
442 let_assert!(Ok(datetime) = DateTime::new((2021, 6, 10), (8, 0, 0)));
443
444 let_assert!(Ok(_) = day.update_dur(&datetime));
445 assert!(day.duration_secs() == 0);
446 }
447
448 #[test]
449 fn test_add_entry() {
450 let_assert!(Ok(mut day) = Day::new("2021-06-10"));
451
452 let_assert!(Ok(entry) = Entry::from_line("2021-06-10 08:00:00 +proj1 do something"));
453 let_assert!(Ok(_) = day.add_entry(entry));
454 let_assert!(Ok(entry) = Entry::from_line("2021-06-10 08:45:00 stop"));
455 let_assert!(Ok(_) = day.add_entry(entry));
456 assert!(day.duration_secs() == 45 * 60);
457 }
458
459 #[test]
460 fn test_format_dur_default() {
461 assert!(format_dur(&Duration::default()) == String::from(" 0:00"));
462 }
463
464 #[rstest]
465 #[case(3600, " 1:00")]
466 #[case(3629, " 1:00")]
467 #[case(3630, " 1:01")]
468 #[case(3660, " 1:01")]
469 #[case(36000, "10:00")]
470 #[case(360000, "4d 4:00")]
471 #[case(300000, "3d 11:20")]
472 fn test_format_dur(#[case]secs: u64, #[case]expected: &str) {
473 assert!(format_dur(&Duration::from_secs(secs)) == String::from(expected));
474 }
475
476 #[test]
477 fn test_new_empty() {
478 let_assert!(Ok(day) = Day::new("2021-06-10"));
479
480 assert!(day.is_empty());
481 assert!(day.duration_secs() == 0u64);
482 assert!(day.is_complete());
483 assert!(day.date_stamp() == String::from("2021-06-10"));
484 }
485
486 pub fn add_entries(day: &mut Day) -> crate::Result<()> {
487 let_assert!(Ok(datetime) = DateTime::new((2021, 6, 10), (8, 0, 0)));
488 add_some_entries(
489 day,
490 datetime,
491 INITIAL_ENTRIES.iter()
492 )?;
493 day.finish()?;
494 Ok(())
495 }
496
497 pub fn add_some_events(day: &mut Day) -> crate::Result<()> {
498 let_assert!(Ok(stamp) = DateTime::new((2021, 6, 10), (8, 0, 0)));
499 for (entry, mins) in SOME_EVENTS.iter() {
500 let_assert!(Ok(shifted_stamp) = stamp + DateTime::minutes(*mins));
501 let ev = Entry::new_marked(
502 entry,
503 shifted_stamp,
504 EntryKind::Event
505 );
506 day.add_entry(ev)?;
507 }
508 day.finish()?;
509 Ok(())
510 }
511
512 pub fn add_extra_entries(day: &mut Day) -> crate::Result<()> {
513 let_assert!(Ok(datetime) = DateTime::new((2021, 6, 10), (8, 0, 0)));
514 add_some_entries(
515 day,
516 datetime,
517 INITIAL_ENTRIES.iter().chain(MORE_ENTRIES.iter())
518 )?;
519 day.finish()?;
520 Ok(())
521 }
522
523 fn add_some_entries<'b, I>(day: &mut Day, stamp: DateTime, entries: I) -> crate::Result<()>
524 where
525 I: Iterator<Item = &'b (&'b str, u64)>
526 {
527 for (entry, mins) in entries {
528 let_assert!(Ok(shifted_stamp) = stamp + DateTime::minutes(*mins));
529 let ev = Entry::new(entry, shifted_stamp);
530 day.add_entry(ev)?;
531 }
532 Ok(())
533 }
534
535 #[test]
536 fn test_task_percentages() {
537 let_assert!(Ok(mut day) = Day::new("2021-06-10"));
538
539 let_assert!(Ok(_) = add_extra_entries(&mut day));
540
541 let expect: Percentages = vec![
542 TagPercent::new("Make (changes)", 40.0).expect("Hardcoded value"),
543 TagPercent::new("Stuff (Other changes)", 40.0).expect("Hardcoded value"),
544 TagPercent::new("Final", 20.0).expect("Hardcoded value"),
545 ];
546 let mut actual = day.task_percentages("proj1");
547 actual.sort_by(|lhs, rhs| lhs.partial_cmp(rhs).expect("Hardcoded value"));
548 assert!(actual == expect);
549 }
550
551 #[test]
552 fn test_entries() {
553 let_assert!(Ok(mut day) = Day::new("2021-06-10"));
554
555 let_assert!(Ok(_) = add_extra_entries(&mut day));
556
557 assert!(day.entries().count() == 9);
558 #[rustfmt::skip]
559 let expected = [
560 (DateTime::new((2021, 6, 10), (8, 0, 0)).expect("Hardcoded value"), "proj1", 60),
561 (DateTime::new((2021, 6, 10), (8, 1, 0)).expect("Hardcoded value"), "proj2", 60),
562 (DateTime::new((2021, 6, 10), (8, 2, 0)).expect("Hardcoded value"), "proj1", 60),
563 (DateTime::new((2021, 6, 10), (8, 3, 0)).expect("Hardcoded value"), "proj1", 60),
564 (DateTime::new((2021, 6, 10), (8, 4, 0)).expect("Hardcoded value"), "proj1", 60),
565 (DateTime::new((2021, 6, 10), (8, 5, 0)).expect("Hardcoded value"), "proj1", 60),
566 (DateTime::new((2021, 6, 10), (9, 0, 0)).expect("Hardcoded value"), "proj3", 60),
567 (DateTime::new((2021, 6, 10), (9, 1, 0)).expect("Hardcoded value"), "proj4", 60),
568 (DateTime::new((2021, 6, 10), (9, 2, 0)).expect("Hardcoded value"), "", 120),
569 ];
570 for (ev, expect) in day.entries().zip(expected.iter()) {
571 assert!(ev.start() == &expect.0);
572 assert!(ev.proj().unwrap_or_default() == expect.1.to_string());
573 assert!(ev.as_secs() == expect.2);
574 }
575 }
576}