mostly defined the chart formatter
This commit is contained in:
parent
8420e7f776
commit
88371b312e
|
@ -7,10 +7,11 @@ use std::collections::HashMap;
|
|||
use directories::{UserDirs, ProjectDirs};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use toml::to_string;
|
||||
use chrono::Weekday;
|
||||
|
||||
use crate::{error::{Result, Error::{self, *}}, formatters::Formatter};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
|
||||
#[derive(Debug, Serialize, Deserialize, Copy, Clone, PartialEq)]
|
||||
pub enum WeekDay {
|
||||
Monday,
|
||||
Tuesday,
|
||||
|
@ -38,7 +39,54 @@ impl FromStr for WeekDay {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
impl From<Weekday> for WeekDay {
|
||||
fn from(wd: Weekday) -> WeekDay {
|
||||
match wd {
|
||||
Weekday::Mon => WeekDay::Monday,
|
||||
Weekday::Tue => WeekDay::Tuesday,
|
||||
Weekday::Wed => WeekDay::Wednesday,
|
||||
Weekday::Thu => WeekDay::Thursday,
|
||||
Weekday::Fri => WeekDay::Friday,
|
||||
Weekday::Sat => WeekDay::Saturday,
|
||||
Weekday::Sun => WeekDay::Sunday,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ChartFormatterSettings {
|
||||
/// This setting is used to highlight hours that go beyond the daily goal.
|
||||
/// If unset all hours will look the same.
|
||||
pub daily_goal_hours: u32,
|
||||
|
||||
/// If set, weekly hour count will be highlighted in green if equal or
|
||||
/// higher than the weekly goal or in red if lower. If not set number will
|
||||
/// be displayed in the default color.
|
||||
pub weekly_goal_hours: u32,
|
||||
|
||||
/// This is the amount of minutes that each character represents in the
|
||||
/// chart
|
||||
pub character_equals_minutes: usize,
|
||||
}
|
||||
|
||||
impl Default for ChartFormatterSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
daily_goal_hours: 0,
|
||||
weekly_goal_hours: 0,
|
||||
character_equals_minutes: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct FormattersSettings {
|
||||
pub chart: ChartFormatterSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
#[serde(skip)]
|
||||
|
@ -87,6 +135,9 @@ pub struct Config {
|
|||
/// and kill)
|
||||
pub interactive_entries: usize,
|
||||
|
||||
/// Individual settings for each formatter
|
||||
pub formatters: FormattersSettings,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
@ -286,6 +337,7 @@ impl Default for Config {
|
|||
note_editor: None,
|
||||
week_start: WeekDay::Monday,
|
||||
interactive_entries: 5,
|
||||
formatters: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ pub mod json;
|
|||
pub mod ids;
|
||||
pub mod ical;
|
||||
pub mod custom;
|
||||
pub mod chart;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
|
@ -22,6 +23,7 @@ pub enum Formatter {
|
|||
Json,
|
||||
Ids,
|
||||
Ical,
|
||||
Chart,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
|
@ -49,6 +51,7 @@ impl Formatter {
|
|||
Formatter::Json => json::print_formatted(entries, out)?,
|
||||
Formatter::Ids => ids::print_formatted(entries, out)?,
|
||||
Formatter::Ical => ical::print_formatted(entries, out, facts.now)?,
|
||||
Formatter::Chart => chart::print_formatted(entries, out, facts)?,
|
||||
Formatter::Custom(name) => custom::print_formatted(name, entries, out, err, facts)?,
|
||||
}
|
||||
|
||||
|
@ -66,6 +69,7 @@ impl FromStr for Formatter {
|
|||
"json" => Formatter::Json,
|
||||
"ids" => Formatter::Ids,
|
||||
"ical" => Formatter::Ical,
|
||||
"chart" => Formatter::Chart,
|
||||
custom_format => Formatter::Custom(custom_format.into()),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -0,0 +1,260 @@
|
|||
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
|
||||
}
|
||||
|
||||
pub fn print_formatted<W: Write>(entries: Vec<Entry>, out: &mut W, facts: &Facts) -> Result<()> {
|
||||
if entries.is_empty() {
|
||||
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());
|
||||
week_accumulated += hours;
|
||||
|
||||
if current_day == start_of_week && i != 0 {
|
||||
tabs.separator(' ');
|
||||
tabs.feed(vec![
|
||||
String::from(""),
|
||||
String::from("Week"),
|
||||
if week_accumulated >= weekly_goal_hours {
|
||||
Green.paint(format!("{week_accumulated:.1}")).to_string()
|
||||
} else {
|
||||
Red.paint(format!("{week_accumulated:.1}")).to_string()
|
||||
},
|
||||
]);
|
||||
tabs.separator(' ');
|
||||
|
||||
week_accumulated = 0.0;
|
||||
}
|
||||
|
||||
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
|
||||
out.push_str(&Style::new().on(White).paint(" ".repeat(daily_goal_blocks - greens)).to_string());
|
||||
} else if total_blocks > daily_goal_blocks {
|
||||
out.push_str(&Style::new().on(Fixed(10)).paint(" ".repeat(total_blocks - daily_goal_blocks)).to_string());
|
||||
}
|
||||
|
||||
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"),
|
||||
if week_accumulated >= weekly_goal_hours {
|
||||
Green.paint(format!("{week_accumulated:.1}")).to_string()
|
||||
} else {
|
||||
Red.paint(format!("{week_accumulated:.1}")).to_string()
|
||||
},
|
||||
]);
|
||||
|
||||
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 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 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 5
|
||||
16 Tue 3.5
|
||||
17 Wed 4
|
||||
18 Thu 2
|
||||
|
||||
Week 20h
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partitioned_week() {
|
||||
let entries = vec![
|
||||
];
|
||||
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 25 Mon 3
|
||||
26 Tue 4
|
||||
27 Wed 2
|
||||
28 Thu 2.5
|
||||
29 Fri 4.5
|
||||
30 Sat 1
|
||||
31 Sun 1.2
|
||||
|
||||
Week 19h
|
||||
|
||||
Sep 1 Mon 3
|
||||
2 Tue 4
|
||||
3 Wed 2
|
||||
4 Thu 2.5
|
||||
5 Fri 4.5
|
||||
|
||||
Week 20h
|
||||
");
|
||||
}
|
||||
|
||||
#[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\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_set_default_formatter_per_command() {
|
||||
assert!(false, "just to remind me. This new formatter should be the default for week and month displays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_without_hours_appear() {
|
||||
assert!(false, "for completeness, if a date between the first shown date and the last one doesnt have any hours registered it should show as 0");
|
||||
}
|
||||
}
|
|
@ -31,4 +31,12 @@ impl Entry {
|
|||
pub fn timespan(&self) -> Option<Duration> {
|
||||
self.end.map(|e| e - self.start)
|
||||
}
|
||||
|
||||
// returns the number of hours of this entry as decimal. If entry is
|
||||
// unfinished return its elapsed time so far.
|
||||
pub fn hours(&self, now: DateTime<Utc>) -> f64 {
|
||||
let d = self.end.unwrap_or(now) - self.start;
|
||||
|
||||
d.num_hours() as f64 + (d.num_minutes() % 60) as f64 / 60.0 + (d.num_seconds() % 60) as f64 / 3600.0
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,15 +2,49 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use ansi_term::Style;
|
||||
use regex::Regex;
|
||||
|
||||
lazy_static! {
|
||||
// https://en.wikipedia.org/wiki/ANSI_escape_code#DOS,_OS/2,_and_Windows
|
||||
//
|
||||
// For Control Sequence Introducer, or CSI, commands, the ESC [ is followed
|
||||
// by any number (including none) of "parameter bytes" in the range
|
||||
// 0x30–0x3F (ASCII 0–9:;<=>?), then by any number of "intermediate bytes"
|
||||
// in the range 0x20–0x2F (ASCII space and !"#$%&'()*+,-./), then finally
|
||||
// by a single "final byte" in the range 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~)
|
||||
//
|
||||
// The lazy regex bellow doesn't cover all of that. It just works on ansi
|
||||
// colors.
|
||||
pub static ref ANSI_REGEX: Regex = Regex::new("\x1b\\[[\\d;]*m").unwrap();
|
||||
}
|
||||
|
||||
/// An abstract way of getting the visual size of a string in a terminal
|
||||
pub trait VisualSize {
|
||||
fn size(&self) -> usize;
|
||||
}
|
||||
|
||||
impl VisualSize for &str {
|
||||
fn size(&self) -> usize {
|
||||
let s = ANSI_REGEX.replace_all(self, "");
|
||||
|
||||
s.chars().count()
|
||||
}
|
||||
}
|
||||
|
||||
impl VisualSize for String {
|
||||
fn size(&self) -> usize {
|
||||
self.as_str().size()
|
||||
}
|
||||
}
|
||||
|
||||
fn lpad(s: &str, len: usize) -> String {
|
||||
let padding = " ".repeat(len.saturating_sub(s.chars().count()));
|
||||
let padding = " ".repeat(len.saturating_sub(s.size()));
|
||||
|
||||
padding + s
|
||||
}
|
||||
|
||||
fn rpad(s: &str, len: usize) -> String {
|
||||
let padding = " ".repeat(len.saturating_sub(s.chars().count()));
|
||||
let padding = " ".repeat(len.saturating_sub(s.size()));
|
||||
|
||||
s.to_string() + &padding
|
||||
}
|
||||
|
@ -110,10 +144,10 @@ impl Tabulate {
|
|||
for (col, ((w, d), c)) in self.widths.iter_mut().zip(data.iter()).zip(self.cols.iter()).enumerate() {
|
||||
for (r1, dl) in d.as_ref().split('\n').enumerate() {
|
||||
for (r2, l) in constrained_lines(dl, c.max_width.unwrap_or(usize::MAX)).into_iter().enumerate() {
|
||||
let count = l.chars().count();
|
||||
let width = l.as_ref().size();
|
||||
|
||||
if count > *w {
|
||||
*w = count;
|
||||
if width > *w {
|
||||
*w = width;
|
||||
}
|
||||
|
||||
if let Some(line) = lines.get_mut(r1 + r2) {
|
||||
|
@ -189,6 +223,7 @@ impl Tabulate {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use ansi_term::Color::Fixed;
|
||||
|
||||
use super::*;
|
||||
|
||||
|
@ -425,4 +460,11 @@ foo key
|
|||
{} foo
|
||||
", Style::new().dimmed().paint("key")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_of_things() {
|
||||
assert_eq!("🥦".size(), 1);
|
||||
assert_eq!("á".size(), 1);
|
||||
assert_eq!(Fixed(10).paint("hola").to_string().size(), 4);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -235,7 +235,7 @@ mod tests {
|
|||
time_diff(parse_time("forty one minutes ago").unwrap(), Local::now() - Duration::minutes(41));
|
||||
time_diff(parse_time("1 minute ago").unwrap(), Local::now() - Duration::minutes(1));
|
||||
time_diff(parse_time("23 minutes ago").unwrap(), Local::now() - Duration::minutes(23));
|
||||
time_diff(parse_time("half an hour ago").unwrap(), dbg!(Local::now() - Duration::minutes(30)));
|
||||
time_diff(parse_time("half an hour ago").unwrap(), Local::now() - Duration::minutes(30));
|
||||
|
||||
// mixed
|
||||
time_diff(parse_time("an hour 10 minutes ago").unwrap(), Local::now() - Duration::minutes(70));
|
||||
|
|
Loading…
Reference in New Issue