2022-08-28 15:48:47 -05:00
|
|
|
use std::io::Write;
|
|
|
|
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<Local>,
|
|
|
|
end: Date<Local>,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl Dates {
|
|
|
|
fn range(from: Date<Local>, to: Date<Local>) -> Dates {
|
|
|
|
Dates {
|
|
|
|
current: from,
|
|
|
|
end: to,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl Iterator for Dates {
|
|
|
|
type Item = Date<Local>;
|
|
|
|
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
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}")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-28 15:48:47 -05:00
|
|
|
pub fn print_formatted<W: Write>(entries: Vec<Entry>, out: &mut W, facts: &Facts) -> Result<()> {
|
|
|
|
if entries.is_empty() {
|
2022-08-29 18:04:15 -05:00
|
|
|
writeln!(out, "No entries to display")?;
|
2022-08-28 15:48:47 -05:00
|
|
|
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"),
|
2022-08-29 18:04:15 -05:00
|
|
|
week_total(week_accumulated, weekly_goal_hours),
|
2022-08-28 15:48:47 -05:00
|
|
|
]);
|
|
|
|
tabs.separator(' ');
|
|
|
|
|
|
|
|
week_accumulated = 0.0;
|
|
|
|
}
|
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
week_accumulated += hours;
|
|
|
|
|
2022-08-28 15:48:47 -05:00
|
|
|
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
|
2022-08-29 22:41:50 -05:00
|
|
|
out.push_str(&Style::new().on(White).paint(" ".repeat(daily_goal_blocks - greens)));
|
2022-08-28 15:48:47 -05:00
|
|
|
} else if total_blocks > daily_goal_blocks {
|
2022-08-29 22:41:50 -05:00
|
|
|
out.push_str(&Style::new().on(Fixed(10)).paint(" ".repeat(total_blocks - daily_goal_blocks)));
|
2022-08-28 15:48:47 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
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"),
|
2022-08-29 18:04:15 -05:00
|
|
|
week_total(week_accumulated, weekly_goal_hours),
|
2022-08-28 15:48:47 -05:00
|
|
|
]);
|
|
|
|
|
|
|
|
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};
|
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
use crate::config::{Config, FormattersSettings, ChartFormatterSettings};
|
|
|
|
|
2022-08-28 15:48:47 -05:00
|
|
|
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();
|
2022-08-29 18:04:15 -05:00
|
|
|
let config = Config {
|
|
|
|
formatters: FormattersSettings {
|
|
|
|
chart: ChartFormatterSettings {
|
|
|
|
daily_goal_hours: 4,
|
|
|
|
weekly_goal_hours: 20,
|
|
|
|
..Default::default()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
..Default::default()
|
|
|
|
};
|
|
|
|
let facts = Facts::new().with_config(config);
|
2022-08-28 15:48:47 -05:00
|
|
|
|
|
|
|
print_formatted(entries, &mut out, &facts).unwrap();
|
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
assert_str_eq!(String::from_utf8_lossy(&out), " Date Day Chart Hours
|
2022-08-28 15:48:47 -05:00
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
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
|
2022-08-28 15:48:47 -05:00
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
Week \u{1b}[31m14.5\u{1b}[0m/20.0
|
2022-08-28 15:48:47 -05:00
|
|
|
");
|
|
|
|
}
|
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
/// If entries span more than one week, both are shown with a weekly result
|
2022-08-28 15:48:47 -05:00
|
|
|
#[test]
|
|
|
|
fn partitioned_week() {
|
2022-08-29 18:04:15 -05:00
|
|
|
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);
|
|
|
|
|
2022-08-28 15:48:47 -05:00
|
|
|
let entries = vec![
|
2022-08-29 18:04:15 -05:00
|
|
|
Entry::new_sample(1, day1, Some(day1 + Duration::hours(5))),
|
|
|
|
Entry::new_sample(2, day2, Some(day2 + Duration::hours(3))),
|
2022-08-28 15:48:47 -05:00
|
|
|
];
|
|
|
|
let mut out = Vec::new();
|
|
|
|
let facts = Facts::new();
|
|
|
|
|
|
|
|
print_formatted(entries, &mut out, &facts).unwrap();
|
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
assert_str_eq!(String::from_utf8_lossy(&out), " Date Day Chart Hours
|
2022-08-28 15:48:47 -05:00
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
Aug 28 Sun \u{1b}[48;5;10m \u{1b}[0m 5.0
|
2022-08-28 15:48:47 -05:00
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
Week 5.0
|
2022-08-28 15:48:47 -05:00
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
Aug 29 Mon \u{1b}[48;5;10m \u{1b}[0m 3.0
|
2022-08-28 15:48:47 -05:00
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
Week 3.0
|
2022-08-28 15:48:47 -05:00
|
|
|
");
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn empty_search() {
|
|
|
|
let entries = Vec::new();
|
|
|
|
let mut out = Vec::new();
|
|
|
|
let facts = Facts::new();
|
|
|
|
|
|
|
|
print_formatted(entries, &mut out, &facts).unwrap();
|
|
|
|
|
2022-08-29 18:04:15 -05:00
|
|
|
assert_str_eq!(String::from_utf8_lossy(&out), "No entries to display\n");
|
2022-08-28 15:48:47 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn days_without_hours_appear() {
|
2022-08-29 18:04:15 -05:00
|
|
|
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
|
|
|
|
");
|
2022-08-28 15:48:47 -05:00
|
|
|
}
|
|
|
|
}
|