use std::io::Write; use std::fmt::Write as _; use std::collections::HashMap; use crate::tabulate::{Tabulate, Col, Align::*}; use chrono::{Local, Datelike, Date, Duration}; use ansi_term::{Style, Color::{Green, Red, White, Fixed}}; use crate::commands::Facts; use crate::models::Entry; use crate::error::Result; use crate::config::WeekDay; struct Dates { current: Date, end: Date, } impl Dates { fn range(from: Date, to: Date) -> Dates { Dates { current: from, end: to, } } } impl Iterator for Dates { type Item = Date; fn next(&mut self) -> Option { if self.current > self.end { None } else { let val = self.current; self.current = self.current + Duration::days(1); Some(val) } } } /// Takes a floating-point amount of hours and returns an integer representing /// the number of whole blocks of size `block_minutes` that fit the amount of /// hours. fn hour_blocks(hours: f64, block_minutes: usize) -> usize { (hours * 60.0) as usize / block_minutes } fn week_total(week_accumulated: f64, weekly_goal_hours: f64) -> String { let formatted_hours = if weekly_goal_hours == 0.0 { format!("{week_accumulated:.1}") } else if week_accumulated >= weekly_goal_hours { Green.paint(format!("{week_accumulated:.1}")).to_string() } else { Red.paint(format!("{week_accumulated:.1}")).to_string() }; formatted_hours + &if weekly_goal_hours == 0.0 { String::from("") } else { format!("/{weekly_goal_hours:.1}") } } pub fn print_formatted(entries: Vec, out: &mut W, facts: &Facts) -> Result<()> { if entries.is_empty() { writeln!(out, "No entries to display")?; return Ok(()); } let mut tabs = Tabulate::with_columns(vec![ Col::new().and_alignment(Right), // date Col::new().and_alignment(Right), // day of week Col::new().and_alignment(Left), // chart Col::new().and_alignment(Left), // hours ]); // Lets group entries by their date and compute some values let mut entries_by_date = HashMap::new(); let mut first_date = None; let mut last_date = None; for entry in entries.into_iter() { let entrys_date = entry.start.with_timezone(&Local).date(); let hours = entry.hours(facts.now); if first_date.is_none() { first_date = Some(entrys_date); } else { first_date = first_date.map(|d| d.min(entrys_date)); } if last_date.is_none() { last_date = Some(entrys_date); } else { last_date = last_date.map(|d| d.max(entrys_date)); } let e = entries_by_date.entry(entrys_date).or_insert(0.0); *e += hours; } tabs.feed(vec![ "Date", "Day", "Chart", "Hours", ]); tabs.separator(' '); let start_of_week = facts.config.week_start; let mut week_accumulated = 0.0; let dates = Dates::range(first_date.unwrap(), last_date.unwrap()); let daily_goal_hours = facts.config.formatters.chart.daily_goal_hours; let weekly_goal_hours = facts.config.formatters.chart.weekly_goal_hours as f64; let block_size_minutes = facts.config.formatters.chart.character_equals_minutes; for (i, date) in dates.enumerate() { let hours = *entries_by_date.get(&date).unwrap_or(&0.0); let current_day = WeekDay::from(date.weekday()); if current_day == start_of_week && i != 0 { tabs.separator(' '); tabs.feed(vec![ String::from(""), String::from("Week"), week_total(week_accumulated, weekly_goal_hours), ]); tabs.separator(' '); week_accumulated = 0.0; } week_accumulated += hours; let daily_goal_blocks = hour_blocks(daily_goal_hours as f64, block_size_minutes); let chart_print = { // first print at most `daily_goal_blocks` characters in green let total_blocks = hour_blocks(hours, block_size_minutes); let greens = daily_goal_blocks.min(total_blocks); let mut out = if greens > 0 { Style::new().on(Green).paint(" ".repeat(greens)).to_string() } else { String::from("") }; if greens < daily_goal_blocks { // print the missing blocks in gray write!(&mut out, "{}", Style::new().on(White).paint(" ".repeat(daily_goal_blocks - greens))).unwrap(); } else if total_blocks > daily_goal_blocks { write!(&mut out, "{}", Style::new().on(Fixed(10)).paint(" ".repeat(total_blocks - daily_goal_blocks))).unwrap(); } out }; tabs.feed(vec![ if i == 0 || current_day == start_of_week || date.day() == 1 { format!("{} {:>2}", date.format("%b"), date.day()) } else { date.day().to_string() }, date.weekday().to_string(), chart_print, format!("{hours:.1}"), ]); } // last week wasn't shown, so lets show it tabs.separator(' '); tabs.feed(vec![ String::from(""), String::from("Week"), week_total(week_accumulated, weekly_goal_hours), ]); out.write_all(tabs.print(facts.env.stdout_is_tty).as_bytes())?; Ok(()) } #[cfg(test)] mod tests { use pretty_assertions::assert_str_eq; use chrono::{Utc, TimeZone, Duration}; use crate::config::{Config, FormattersSettings, ChartFormatterSettings}; use super::*; #[test] fn sample_printing() { std::env::set_var("TZ", "CST+6"); let day1 = Utc.ymd(2022, 8, 15).and_hms(12, 0, 0); let day2 = Utc.ymd(2022, 8, 16).and_hms(12, 0, 0); let day3 = Utc.ymd(2022, 8, 17).and_hms(12, 0, 0); let day4 = Utc.ymd(2022, 8, 18).and_hms(12, 0, 0); let entries = vec![ Entry::new_sample(1, day1, Some(day1 + Duration::hours(5))), Entry::new_sample(2, day2, Some(day2 + Duration::minutes(60 * 3 + 30))), Entry::new_sample(3, day3, Some(day3 + Duration::hours(4))), Entry::new_sample(4, day4, Some(day4 + Duration::hours(2))), ]; let mut out = Vec::new(); let config = Config { formatters: FormattersSettings { chart: ChartFormatterSettings { daily_goal_hours: 4, weekly_goal_hours: 20, ..Default::default() }, extra: HashMap::new(), }, ..Default::default() }; let facts = Facts::new().with_config(config); print_formatted(entries, &mut out, &facts).unwrap(); assert_str_eq!(String::from_utf8_lossy(&out), " Date Day Chart Hours Aug 15 Mon \u{1b}[42m \u{1b}[0m\u{1b}[48;5;10m \u{1b}[0m 5.0 16 Tue \u{1b}[42m \u{1b}[0m\u{1b}[47m \u{1b}[0m 3.5 17 Wed \u{1b}[42m \u{1b}[0m 4.0 18 Thu \u{1b}[42m \u{1b}[0m\u{1b}[47m \u{1b}[0m 2.0 Week \u{1b}[31m14.5\u{1b}[0m/20.0 "); } /// If entries span more than one week, both are shown with a weekly result #[test] fn partitioned_week() { std::env::set_var("TZ", "CST+6"); let day1 = Utc.ymd(2022, 8, 28).and_hms(12, 0, 0); let day2 = Utc.ymd(2022, 8, 29).and_hms(12, 0, 0); let entries = vec![ Entry::new_sample(1, day1, Some(day1 + Duration::hours(5))), Entry::new_sample(2, day2, Some(day2 + Duration::hours(3))), ]; let mut out = Vec::new(); let facts = Facts::new(); print_formatted(entries, &mut out, &facts).unwrap(); assert_str_eq!(String::from_utf8_lossy(&out), " Date Day Chart Hours Aug 28 Sun \u{1b}[48;5;10m \u{1b}[0m 5.0 Week 5.0 Aug 29 Mon \u{1b}[48;5;10m \u{1b}[0m 3.0 Week 3.0 "); } #[test] fn empty_search() { let entries = Vec::new(); let mut out = Vec::new(); let facts = Facts::new(); print_formatted(entries, &mut out, &facts).unwrap(); assert_str_eq!(String::from_utf8_lossy(&out), "No entries to display\n"); } #[test] fn days_without_hours_appear() { std::env::set_var("TZ", "CST+6"); let day1 = Utc.ymd(2022, 8, 15).and_hms(12, 0, 0); let day3 = Utc.ymd(2022, 8, 17).and_hms(12, 0, 0); let entries = vec![ Entry::new_sample(1, day1, Some(day1 + Duration::hours(5))), Entry::new_sample(3, day3, Some(day3 + Duration::hours(4))), ]; let mut out = Vec::new(); let facts = Facts::new(); print_formatted(entries, &mut out, &facts).unwrap(); assert_str_eq!(String::from_utf8_lossy(&out), " Date Day Chart Hours Aug 15 Mon \u{1b}[48;5;10m \u{1b}[0m 5.0 16 Tue 0.0 17 Wed \u{1b}[48;5;10m \u{1b}[0m 4.0 Week 9.0 "); } #[test] fn display_without_goals_set() { std::env::set_var("TZ", "CST+6"); let day1 = Utc.ymd(2022, 8, 15).and_hms(12, 0, 0); let day2 = Utc.ymd(2022, 8, 16).and_hms(12, 0, 0); let day3 = Utc.ymd(2022, 8, 17).and_hms(12, 0, 0); let day4 = Utc.ymd(2022, 8, 18).and_hms(12, 0, 0); let entries = vec![ Entry::new_sample(1, day1, Some(day1 + Duration::hours(5))), Entry::new_sample(2, day2, Some(day2 + Duration::minutes(60 * 3 + 30))), Entry::new_sample(3, day3, Some(day3 + Duration::hours(4))), Entry::new_sample(4, day4, Some(day4 + Duration::hours(2))), ]; let mut out = Vec::new(); let facts = Facts::new(); print_formatted(entries, &mut out, &facts).unwrap(); assert_str_eq!(String::from_utf8_lossy(&out), " Date Day Chart Hours Aug 15 Mon \u{1b}[48;5;10m \u{1b}[0m 5.0 16 Tue \u{1b}[48;5;10m \u{1b}[0m 3.5 17 Wed \u{1b}[48;5;10m \u{1b}[0m 4.0 18 Thu \u{1b}[48;5;10m \u{1b}[0m 2.0 Week 14.5 "); } }