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: 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 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 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}