use std::io::Write; use itertools::Itertools; use chrono::{ Duration, Local, NaiveTime, Timelike, NaiveDateTime }; use crate::models::Entry; use crate::error::Result; use crate::tabulate::{Tabulate, Col, Align::*}; use crate::commands::Facts; pub fn format_duration(dur: Duration) -> String { format!("{}:{:02}:{:02}", dur.num_hours(), dur.num_minutes() % 60, dur.num_seconds() % 60) } pub fn format_hours(dur: Duration) -> String { let hours = dur.num_hours(); let minutes = dur.num_minutes() % 60; let seconds = dur.num_seconds() % 60; let time = [ if hours > 0 { Some(format!("{hours}h")) } else { None }, if minutes > 0 { Some(format!("{minutes}m")) } else { None }, if seconds > 0 { Some(format!("{seconds}s")) } else { None }, ].into_iter().flatten().collect::>().join(" "); if time.is_empty() { String::from("less than one second") } else { time } } fn format_start(t: NaiveTime) -> String { format!("{:02}:{:02}:{:02}", t.hour(), t.minute(), t.second()) } fn format_end(start: NaiveDateTime, end: NaiveDateTime) -> String { let extra_days = (end - start).num_days(); let d = if extra_days > 0 { format!("+{}d", extra_days) } else { "".into() }; format!( "{:02}:{:02}:{:02}{}", end.hour(), end.minute() % 60, end.second() % 60, d ) } /// Print in the default text format. Assume entries are sorted by sheet and /// then by start pub fn print_formatted(entries: Vec, out: &mut W, facts: &Facts, ids: bool) -> Result<()> { let grouped_entries = entries.into_iter().group_by(|e| e.sheet.to_string()); let mut num_sheets = 0; let mut grand_total = Duration::seconds(0); for (i, (key, group)) in grouped_entries.into_iter().enumerate() { num_sheets += 1; if i != 0 { writeln!(out)?; } writeln!(out, "Timesheet: {}", key)?; // A vector of lines to be printed, with all the components let mut tabs = Tabulate::with_columns(vec![ Col::new().min_width(3).and_alignment(Right), Col::new().min_width(18).and_alignment(Left), Col::new().min_width(21).and_alignment(Left), Col::new().min_width(8).and_alignment(Right), Col::new().min_width(5).max_width(44).and_alignment(Left), ]); if ids { tabs.feed(vec![ "ID", "Day", "Start End", "Duration", "Notes", ]); } else { tabs.feed(vec![ "", "Day", "Start End", "Duration", "Notes", ]); } let entries_by_date = group.group_by(|e| e.start.with_timezone(&Local).date()); let mut total = Duration::seconds(0); for (date, entries) in entries_by_date.into_iter() { let mut daily = Duration::seconds(0); for (i, entry) in entries.into_iter().enumerate() { let startend = format!("{} - {}", format_start(entry.start.with_timezone(&Local).time()), entry.end.map(|t| { format_end( entry.start.with_timezone(&Local).naive_local(), t.with_timezone(&Local).naive_local() ) }).unwrap_or_else(|| "".into()) ); let duration = entry.end.unwrap_or(facts.now) - entry.start; daily = daily + duration; let duration = format_duration(duration); let note = entry.note.unwrap_or_else(|| "".into()); let id = if ids { entry.id.to_string() } else { "".into() }; if i == 0 { let date = date.format("%a %b %d, %Y").to_string(); tabs.feed(vec![id, date, startend, duration, note]); } else { tabs.feed(vec![id, "".into(), startend, duration, note]); } } total = total + daily; tabs.feed(vec!["".to_string(), "".to_string(), "".to_string(), format_duration(daily), "".to_string()]); } tabs.separator('-'); tabs.feed(vec!["".into(), "Total".into(), "".into(), format_duration(total)]); grand_total = grand_total + total; out.write_all(tabs.print(facts.env.stdout_is_tty).as_bytes())?; } if num_sheets > 1 { let mut tabs = Tabulate::with_columns(vec![ Col::new().min_width(3).and_alignment(Right), Col::new().min_width(18).and_alignment(Left), Col::new().min_width(21).and_alignment(Left), Col::new().min_width(8).and_alignment(Right), Col::new().min_width(5).max_width(44).and_alignment(Left), ]); tabs.separator('-'); tabs.feed(vec!["".to_string(), "Grand total".to_string(), "".to_string(), format_duration(grand_total)]); out.write_all(tabs.print(facts.env.stdout_is_tty).as_bytes())?; } Ok(()) } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use chrono::{Utc, TimeZone}; use super::*; const LONG_NOTE: &str = "chatting with bob about upcoming task, district sharing of images, how the user settings currently works etc. Discussing the fingerprinting / cache busting issue with CKEDITOR, suggesting perhaps looking into forking the rubygem and seeing if we can work in our own changes, however hard that might be."; #[test] fn test_text_output() { std::env::set_var("TZ", "CST+6"); let mut output = Vec::new(); let entries = vec![ Entry::new_sample(1, Utc.ymd(2008, 10, 3).and_hms(12, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(14, 0, 0))), Entry::new_sample(2, Utc.ymd(2008, 10, 3).and_hms(16, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(18, 0, 0))), Entry::new_sample(3, Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), Some(Utc.ymd(2008, 10, 5).and_hms(18, 0, 0))), Entry::new_sample(4, Utc.ymd(2008, 10, 5).and_hms(18, 0, 0), None), ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let facts = Facts::new().with_now(now); print_formatted(entries, &mut output, &facts, false).unwrap(); assert_eq!(&String::from_utf8_lossy(&output), "Timesheet: default Day Start End Duration Notes Fri Oct 03, 2008 06:00:00 - 08:00:00 2:00:00 entry 1 10:00:00 - 12:00:00 2:00:00 entry 2 4:00:00 Sun Oct 05, 2008 10:00:00 - 12:00:00 2:00:00 entry 3 12:00:00 - 2:00:00 entry 4 4:00:00 ------------------------------------------------------------- Total 8:00:00 "); } #[test] fn test_text_output_with_millis() { std::env::set_var("TZ", "CST+6"); let mut output = Vec::new(); let entries = vec![ Entry::new_sample(1, Utc.ymd(2008, 10, 3).and_hms_milli(12, 0, 0, 432), Some(Utc.ymd(2008, 10, 3).and_hms_milli(14, 0, 0, 312))), ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let facts = Facts::new().with_now(now); print_formatted(entries, &mut output, &facts, false).unwrap(); assert_eq!(&String::from_utf8_lossy(&output), "Timesheet: default Day Start End Duration Notes Fri Oct 03, 2008 06:00:00 - 08:00:00 1:59:59 entry 1 1:59:59 ------------------------------------------------------------- Total 1:59:59 "); } #[test] fn test_text_output_long_duration() { std::env::set_var("TZ", "CST+6"); let mut output = Vec::new(); let entries = vec![ Entry::new_sample(1, Utc.ymd(2008, 10, 1).and_hms(12, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(14, 0, 0))), Entry::new_sample(2, Utc.ymd(2008, 10, 3).and_hms(12, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(14, 0, 0))), ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let facts = Facts::new().with_now(now); print_formatted(entries, &mut output, &facts, false).unwrap(); assert_eq!(&String::from_utf8_lossy(&output), "Timesheet: default Day Start End Duration Notes Wed Oct 01, 2008 06:00:00 - 08:00:00+2d 50:00:00 entry 1 50:00:00 Fri Oct 03, 2008 06:00:00 - 08:00:00 2:00:00 entry 2 2:00:00 -------------------------------------------------------------- Total 52:00:00 "); } #[test] fn test_text_output_with_ids() { std::env::set_var("TZ", "CST+6"); let mut output = Vec::new(); let entries = vec![ Entry::new_sample(1, Utc.ymd(2008, 10, 3).and_hms(12, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(14, 0, 0))), Entry::new_sample(2, Utc.ymd(2008, 10, 3).and_hms(16, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(18, 0, 0))), Entry::new_sample(3, Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), Some(Utc.ymd(2008, 10, 5).and_hms(18, 0, 0))), Entry::new_sample(4, Utc.ymd(2008, 10, 5).and_hms(18, 0, 0), None), ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let facts = Facts::new().with_now(now); print_formatted(entries, &mut output, &facts, true).unwrap(); assert_eq!(&String::from_utf8_lossy(&output), "Timesheet: default ID Day Start End Duration Notes 1 Fri Oct 03, 2008 06:00:00 - 08:00:00 2:00:00 entry 1 2 10:00:00 - 12:00:00 2:00:00 entry 2 4:00:00 3 Sun Oct 05, 2008 10:00:00 - 12:00:00 2:00:00 entry 3 4 12:00:00 - 2:00:00 entry 4 4:00:00 ------------------------------------------------------------- Total 8:00:00 "); } #[test] fn test_text_output_long_note_with_ids() { std::env::set_var("TZ", "CST+6"); let mut output = Vec::new(); let entries = vec![ Entry { id: 60000, sheet: "default".into(), start: Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), end: Some(Utc.ymd(2008, 10, 5).and_hms(18, 0, 0)), note: Some(LONG_NOTE.into()), }, ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let facts = Facts::new().with_now(now); print_formatted(entries, &mut output, &facts, true).unwrap(); assert_eq!(&String::from_utf8_lossy(&output), "Timesheet: default ID Day Start End Duration Notes 60000 Sun Oct 05, 2008 10:00:00 - 12:00:00 2:00:00 chatting with bob about upcoming task, district sharing of images, how the user settings currently works etc. Discussing the fingerprinting / cache busting issue with CKEDITOR, suggesting perhaps looking into forking the rubygem and seeing if we can work in our own changes, however hard that might be. 2:00:00 ---------------------------------------------------------------------------------------------------- Total 2:00:00 "); } #[test] fn test_text_output_note_with_line_breaks() { std::env::set_var("TZ", "CST+6"); let mut output = Vec::new(); let entries = vec![ Entry { id: 1, sheet: "default".into(), start: Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), end: Some(Utc.ymd(2008, 10, 5).and_hms(18, 0, 0)), note: Some("first line\nand a second line".into()), }, ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let facts = Facts::new().with_now(now); print_formatted(entries, &mut output, &facts, false).unwrap(); assert_eq!(&String::from_utf8_lossy(&output), "Timesheet: default Day Start End Duration Notes Sun Oct 05, 2008 10:00:00 - 12:00:00 2:00:00 first line and a second line 2:00:00 ----------------------------------------------------------------------- Total 2:00:00 "); } #[test] fn note_with_accents() { std::env::set_var("TZ", "CST+6"); let mut output = Vec::new(); let entries = vec![ Entry { id: 1, sheet: "default".into(), start: Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), end: Some(Utc.ymd(2008, 10, 5).and_hms(18, 0, 0)), note: Some("quiúbole".into()), }, ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let facts = Facts::new().with_now(now); print_formatted(entries, &mut output, &facts, false).unwrap(); assert_eq!(&String::from_utf8_lossy(&output), "Timesheet: default Day Start End Duration Notes Sun Oct 05, 2008 10:00:00 - 12:00:00 2:00:00 quiúbole 2:00:00 -------------------------------------------------------------- Total 2:00:00 "); } #[test] fn displays_grand_total_when_multiple_sheets() { std::env::set_var("TZ", "CST+6"); let mut output = Vec::new(); let entries = vec![ Entry { id: 1, sheet: "sheet1".to_string(), start: Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), end: Some(Utc.ymd(2008, 10, 5).and_hms(18, 0, 0)), note: Some("quiúbole".to_string()), }, Entry { id: 2, sheet: "sheet2".to_string(), start: Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), end: Some(Utc.ymd(2008, 10, 5).and_hms(18, 0, 0)), note: Some("quiúbole".to_string()), }, ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let facts = Facts::new().with_now(now); print_formatted(entries, &mut output, &facts, false).unwrap(); assert_eq!(&String::from_utf8_lossy(&output), "Timesheet: sheet1 Day Start End Duration Notes Sun Oct 05, 2008 10:00:00 - 12:00:00 2:00:00 quiúbole 2:00:00 -------------------------------------------------------------- Total 2:00:00 Timesheet: sheet2 Day Start End Duration Notes Sun Oct 05, 2008 10:00:00 - 12:00:00 2:00:00 quiúbole 2:00:00 -------------------------------------------------------------- Total 2:00:00 ----------------------------------------------------------- Grand total 4:00:00 "); } #[test] fn hour_formatting() { assert_eq!(format_hours(Duration::hours(3) + Duration::minutes(30)), "3h 30m"); assert_eq!(format_hours(Duration::hours(3) + Duration::minutes(1)), "3h 1m"); assert_eq!(format_hours(Duration::hours(3) + Duration::minutes(5) + Duration::seconds(59)), "3h 5m 59s"); assert_eq!(format_hours(Duration::hours(3)), "3h"); assert_eq!(format_hours(Duration::milliseconds(543)), "less than one second") } }