From 43f8bc043b4ba05c1673710174a8a019cd58c6af Mon Sep 17 00:00:00 2001 From: aaron-jack-manning Date: Wed, 24 Aug 2022 09:56:23 +1000 Subject: [PATCH] time entry representation change, and basic statistics --- README.md | 12 ++++-- src/main.rs | 21 ++++++++++ src/stats.rs | 59 ++++++++++++++++++++++++++ src/tasks.rs | 115 ++++++++++++++++++++++++++++++++++++--------------- 4 files changed, 170 insertions(+), 37 deletions(-) create mode 100644 src/stats.rs diff --git a/README.md b/README.md index 0102e25..c69e615 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,15 @@ Then you can run `toru new` to create your first task. - Error if any circular dependencies are introduced - Make sure dependencies written to file are only those that could be successfully created - List dependencies as a tree on note view below info -- Automatically added recurring notes system -- Time tracking - - Command to give statistics on time tracking (by tag, and for the last x days) - Due dates - Taken as input when creating notes - Displayed in list view by default (with number of days remaining) +- Completed Date + - Keep track of completed date, and correctly update upon marking as complete or manual edit + - Disallow removing it in a manual edit unless complete is also marked to false + - Add to statistics +- SVN integration +- Statistics + - Completed tasks over last x days + - Improve formatting to terminal to make easier to read for `tracked` command +- Automatically added recurring notes system diff --git a/src/main.rs b/src/main.rs index 78aa0a0..901c086 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod vault; mod error; mod tasks; mod state; +mod stats; mod config; mod colour; @@ -82,6 +83,9 @@ enum Command { #[clap(short, default_value_t=0)] minutes : u16, }, + /// For statistics about the state of your vault. + #[clap(subcommand)] + Stats(StatsCommand), /// For making changes to global configuration. #[clap(subcommand)] Config(ConfigCommand), @@ -94,6 +98,15 @@ enum Command { }, } + +#[derive(clap::Subcommand, Debug, PartialEq, Eq)] +enum StatsCommand { + Tracked { + #[clap(short, long, default_value_t=7)] + days : u16, + }, +} + #[derive(clap::Subcommand, Debug, PartialEq, Eq)] enum ConfigCommand { /// For checking or changing default text editor command. @@ -251,6 +264,14 @@ fn program() -> Result<(), error::Error> { task.data.time_entries.push(entry); task.save()?; }, + Stats(command) => { + use StatsCommand::*; + match command { + Tracked { days } => { + stats::time_per_tag(days, vault_folder)?; + } + } + }, Discard { id_or_name } => { let id = state.name_or_id_to_id(&id_or_name)?; let mut task = tasks::Task::load(id, vault_folder, false)?; diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..41c7fb5 --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,59 @@ +use crate::tasks; +use crate::error; + +use std::path; +use std::collections::HashMap; + +pub fn time_per_tag(days : u16, vault_folder : &path::Path) -> Result<(), error::Error> { + + let tasks = tasks::Task::load_all(vault_folder, true)?; + + let mut times = HashMap::::new(); + + for task in &tasks { + if !task.data.discarded { + let mut time = tasks::Duration::zero(); + + for entry in &task.data.time_entries { + if chrono::Utc::now().naive_local().date() - entry.logged_date < chrono::Duration::days(i64::from(days)) { + time = time + entry.duration; + } + } + + let tag_count = task.data.tags.len(); + let time_per_tag = time / tag_count; + + for tag in &task.data.tags { + match times.get_mut(tag) { + Some(time) => { + *time = *time + time_per_tag; + }, + None => { + times.insert(tag.clone(), time); + } + } + } + } + } + + let mut table = comfy_table::Table::new(); + table + .load_preset(comfy_table::presets::UTF8_FULL) + .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS) + .set_content_arrangement(comfy_table::ContentArrangement::Dynamic); + table.set_header(vec!["Tag", "Time"]); + + for (tag, duration) in × { + + table.add_row( + vec![ + tag.clone(), + duration.to_string(), + ] + ); + } + + println!("{}", table); + + Ok(()) +} diff --git a/src/tasks.rs b/src/tasks.rs index 7a167a6..4592457 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -5,6 +5,7 @@ use crate::colour; use std::io; use std::fs; use std::fmt; +use std::ops; use std::mem; use std::path; use std::io::{Write, Seek}; @@ -53,41 +54,17 @@ impl Priority { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct TimeEntry { - logged_date : chrono::NaiveDate, + pub logged_date : chrono::NaiveDate, + pub duration : Duration, +} + +// Needs to preserve representation invariant of minutes < 60 +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Duration { hours : u16, minutes : u16, } -impl TimeEntry { - /// Adds up a collection of time entries. - fn total(entries : &Vec) -> (u16, u16) { - let (hours, minutes) = - entries - .into_iter() - .fold((0, 0), |a, e| (a.0 + e.hours, a.1 + e.minutes)); - - let (hours, minutes) = { - (hours + minutes / 60, minutes % 60) - }; - - (hours, minutes) - } -} - -impl TimeEntry { - pub fn new(hours : u16, minutes : u16) -> Self { - - let (hours, minutes) = { - (hours + minutes / 60, minutes % 60) - }; - - Self { - logged_date : chrono::Utc::now().naive_local().date(), - hours, - minutes, - } - } -} #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct InternalTask { @@ -291,7 +268,7 @@ impl Task { println!("Time Entries:"); for entry in &entries { - println!(" {}:{:0>2} [{}]", entry.hours, entry.minutes, entry.logged_date); + println!(" {} [{}]", entry.duration, entry.logged_date); } } @@ -332,7 +309,7 @@ pub fn list(vault_folder : &path::Path) -> Result<(), error::Error> { for task in tasks { if !task.data.discarded && !task.data.complete { - let (hours, minutes) = TimeEntry::total(&task.data.time_entries); + let duration = TimeEntry::total(&task.data.time_entries); table.add_row( vec![ @@ -340,7 +317,7 @@ pub fn list(vault_folder : &path::Path) -> Result<(), error::Error> { task.data.name, format_hash_set(&task.data.tags)?, task.data.priority.to_string(), - if (hours, minutes) == (0, 0) { String::new() } else { format!("{}:{:0>2}", hours, minutes) }, + if duration == Duration::zero() { String::new() } else { duration.to_string() }, ] ); } @@ -364,4 +341,74 @@ pub fn clean(vault_folder : &path::Path) -> Result<(), error::Error> { Ok(()) } +impl ops::Add for Duration { + type Output = Self; + + fn add(self, other : Self) -> Self::Output { + + Self { + hours : self.hours + other.hours + (self.minutes + other.minutes) / 60, + minutes : (self.minutes + other.minutes) % 60, + } + } +} + +impl Duration { + pub fn zero() -> Self { + Self { + minutes : 0, + hours : 0, + } + } +} + + +impl ops::Div for Duration { + type Output = Self; + + fn div(self, divisor : usize) -> Self::Output { + let total_mins = f64::from(self.hours * 60 + self.minutes); + let divided_mins = total_mins / divisor as f64; + let divided_mins = divided_mins.round() as u16; + + Self { + hours : divided_mins / 60, + minutes : divided_mins % 60, + } + } +} + +impl fmt::Display for Duration { + fn fmt(&self, f : &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{:0>2}", self.hours, self.minutes) + } +} + +impl TimeEntry { + /// Adds up the times from a collection of time entries. + fn total(entries : &Vec) -> Duration { + entries + .into_iter() + .map(|e| e.duration) + .fold(Duration::zero(), |a, d| a + d) + } +} + +impl TimeEntry { + pub fn new(hours : u16, minutes : u16) -> Self { + + let (hours, minutes) = { + (hours + minutes / 60, minutes % 60) + }; + + Self { + logged_date : chrono::Utc::now().naive_local().date(), + duration : Duration { + hours, + minutes, + } + } + } +} +