Merge pull request #32 from berkes/round

Introduce an option to round to neares N minutes or hours.
This commit is contained in:
Nikolas Schmidt-Voigt 2023-12-06 22:24:50 +01:00 committed by GitHub
commit 4485d79316
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 200 additions and 10 deletions

View file

@ -268,6 +268,7 @@ bartib report --last_week # create a report for the last week
bartib report --date 2021-09-03 # create a report for a given day
bartib report --from 2021-09-01 --to 2021-09-05 # create a report for a given time range
bartib report --project "The most exciting project" # create a report for a given project
bartib report --round 15m # rounds the start and end time to the nearest duration. Durations can be in minutes or hours. E.g. 15m or 4h
bartib list # list all activities grouped by day
bartib list --no_grouping # list all activities but do not group them by day
@ -279,6 +280,7 @@ bartib list --last_week # list activities of the last week
bartib list --date 2021-09-03 # list activities on a given day
bartib list --from 2021-09-01 --to 2021-09-05 # list activities in a given time range
bartib list --project "The most exciting project" # list activities for a given project
bartib list --round 15m # rounds the start and end time to the nearest duration. Durations can be in minutes or hours. E.g. 15m or 4h
```
### Edit activities

View file

@ -1,5 +1,12 @@
use chrono::Duration;
pub static FORMAT_DATETIME: &str = "%F %R";
pub static FORMAT_TIME: &str = "%R";
pub static FORMAT_DATE: &str = "%F";
pub static DEFAULT_WIDTH: usize = usize::MAX;
pub static REPORT_INDENTATION: usize = 4;
#[derive(Debug)]
pub struct ProcessConfig {
pub round: Option<Duration>,
}

View file

@ -6,6 +6,7 @@ use crate::data::activity;
use crate::data::activity::Activity;
use crate::data::bartib_file;
use crate::data::getter;
use crate::data::processor;
use crate::view::list;
// lists all currently running activities.
@ -25,11 +26,16 @@ pub fn list(
file_name: &str,
filter: getter::ActivityFilter,
do_group_activities: bool,
processors: processor::ProcessorList,
) -> Result<()> {
let file_content = bartib_file::get_file_content(file_name)?;
let activities = getter::get_activities(&file_content);
let activities = getter::get_activities(&file_content).collect();
let processed_activities_bind: Vec<activity::Activity> =
processor::process_activities(activities, processors);
let processed_activities: Vec<&activity::Activity> = processed_activities_bind.iter().collect();
let mut filtered_activities: Vec<&activity::Activity> =
getter::filter_activities(activities, &filter).collect();
getter::filter_activities(processed_activities, &filter);
filtered_activities.sort_by_key(|activity| activity.start);

View file

@ -3,13 +3,23 @@ use anyhow::Result;
use crate::data::activity;
use crate::data::bartib_file;
use crate::data::getter;
use crate::data::processor;
use crate::view::report;
pub fn show_report(file_name: &str, filter: getter::ActivityFilter) -> Result<()> {
pub fn show_report(
file_name: &str,
filter: getter::ActivityFilter,
processors: processor::ProcessorList,
) -> Result<()> {
let file_content = bartib_file::get_file_content(file_name)?;
let activities = getter::get_activities(&file_content);
let activities = getter::get_activities(&file_content).collect();
let processed_activities_bind: Vec<activity::Activity> =
processor::process_activities(activities, processors);
let processed_activities: Vec<&activity::Activity> = processed_activities_bind.iter().collect();
let mut filtered_activities: Vec<&activity::Activity> =
getter::filter_activities(activities, &filter).collect();
getter::filter_activities(processed_activities, &filter);
filtered_activities.sort_by_key(|activity| activity.start);

View file

@ -5,7 +5,7 @@ use thiserror::Error;
use crate::conf;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct Activity {
pub start: NaiveDateTime,
pub end: Option<NaiveDateTime>,

View file

@ -75,9 +75,9 @@ pub fn get_activities(
}
pub fn filter_activities<'a>(
activities: impl Iterator<Item = &'a activity::Activity>,
activities: Vec<&'a activity::Activity>,
filter: &'a ActivityFilter,
) -> impl Iterator<Item = &'a activity::Activity> {
) -> Vec<&'a activity::Activity> {
let from_date: NaiveDate;
let to_date: NaiveDate;
@ -90,10 +90,12 @@ pub fn filter_activities<'a>(
}
activities
.into_iter()
.filter(move |activity| {
activity.start.date() >= from_date && activity.start.date() <= to_date
})
.filter(move |activity| filter.project.map_or(true, |p| activity.project == *p))
.collect()
}
#[must_use]

View file

@ -1,3 +1,5 @@
pub mod activity;
pub mod bartib_file;
pub mod getter;
pub mod processor;
pub mod round_util;

43
src/data/processor.rs Normal file
View file

@ -0,0 +1,43 @@
use chrono::Duration;
use crate::data::activity;
use crate::data::round_util::round_datetime;
pub type ProcessorList = Vec<Box<dyn ActivityProcessor>>;
pub trait ActivityProcessor {
fn process(&self, activity: &activity::Activity) -> activity::Activity;
}
pub struct RoundProcessor {
pub round: Duration,
}
impl ActivityProcessor for RoundProcessor {
fn process(&self, activity: &activity::Activity) -> activity::Activity {
let start = round_datetime(&activity.start, &self.round);
let end = activity.end.map(|end| round_datetime(&end, &self.round));
activity::Activity {
start,
end,
project: activity.project.clone(),
description: activity.description.clone(),
}
}
}
pub fn process_activities(
activities: Vec<&activity::Activity>,
processors: ProcessorList,
) -> Vec<activity::Activity> {
activities
.into_iter()
.cloned()
.map(|activity| {
processors
.iter()
.fold(activity, |activity, processor| processor.process(&activity))
})
.collect()
}

68
src/data/round_util.rs Normal file
View file

@ -0,0 +1,68 @@
// Utility functions for rounding datetimes.
// Limitations:
// - Cannot handle days properly.
// - Does not consider DST changes, so rounding to hours may be off by an hour.
// - Does not consider leap seconds.
pub fn round_datetime(
datetime: &chrono::NaiveDateTime,
round: &chrono::Duration,
) -> chrono::NaiveDateTime {
let timestamp = datetime.timestamp();
let round_seconds = round.num_seconds();
let rounded_timestamp =
(timestamp as f64 / round_seconds as f64).round() as i64 * round_seconds;
chrono::NaiveDateTime::from_timestamp_opt(rounded_timestamp, 0).unwrap()
}
#[cfg(test)]
mod tests {
use chrono::{Duration, NaiveDate};
use super::*;
fn fake_date() -> NaiveDate {
NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()
}
#[test]
fn test_round_to_5_minutes() {
let round = Duration::minutes(5);
assert_eq!(
round_datetime(&fake_date().and_hms_opt(13, 32, 30).unwrap(), &round),
fake_date().and_hms_opt(13, 35, 0).unwrap()
);
assert_eq!(
round_datetime(&fake_date().and_hms_opt(13, 31, 1).unwrap(), &round),
fake_date().and_hms_opt(13, 30, 0).unwrap()
);
}
#[test]
fn test_round_to_8_hours() {
let round = Duration::hours(8);
assert_eq!(
round_datetime(&fake_date().and_hms_opt(4, 0, 1).unwrap(), &round),
fake_date().and_hms_opt(8, 0, 0).unwrap()
);
assert_eq!(
round_datetime(&fake_date().and_hms_opt(3, 59, 59).unwrap(), &round),
fake_date().and_hms_opt(0, 0, 0).unwrap()
);
}
#[test]
fn test_round_middle_rounds_up() {
let round = Duration::minutes(10);
assert_eq!(
round_datetime(&fake_date().and_hms_opt(13, 5, 0).unwrap(), &round),
fake_date().and_hms_opt(13, 10, 0).unwrap()
)
}
}

View file

@ -3,6 +3,8 @@ use chrono::{Datelike, Duration, Local, NaiveDate, NaiveTime};
use clap::{crate_version, App, AppSettings, Arg, ArgMatches, SubCommand};
use bartib::data::getter::ActivityFilter;
use bartib::data::processor;
#[cfg(windows)]
use nu_ansi_term::enable_ansi_support;
@ -75,6 +77,12 @@ fn main() -> Result<()> {
])
.takes_value(false);
let arg_group = Arg::with_name("round")
.long("round")
.help("rounds the start and end time to the nearest duration. Durations can be in minutes or hours. E.g. 15m or 4h")
.required(false)
.takes_value(true);
let arg_description = Arg::with_name("description")
.short("d")
.long("description")
@ -153,6 +161,7 @@ fn main() -> Result<()> {
.arg(&arg_yesterday)
.arg(&arg_current_week)
.arg(&arg_last_week)
.arg(&arg_group)
.arg(
Arg::with_name("project")
.short("p")
@ -187,6 +196,7 @@ fn main() -> Result<()> {
.arg(&arg_yesterday)
.arg(&arg_current_week)
.arg(&arg_last_week)
.arg(&arg_group)
.arg(
Arg::with_name("project")
.short("p")
@ -299,12 +309,14 @@ fn run_subcommand(matches: &ArgMatches, file_name: &str) -> Result<()> {
("current", Some(_)) => bartib::controller::list::list_running(file_name),
("list", Some(sub_m)) => {
let filter = create_filter_for_arguments(sub_m);
let processors = create_processors_for_arguments(sub_m);
let do_group_activities = !sub_m.is_present("no_grouping") && filter.date.is_none();
bartib::controller::list::list(file_name, filter, do_group_activities)
bartib::controller::list::list(file_name, filter, do_group_activities, processors)
}
("report", Some(sub_m)) => {
let filter = create_filter_for_arguments(sub_m);
bartib::controller::report::show_report(file_name, filter)
let processors = create_processors_for_arguments(sub_m);
bartib::controller::report::show_report(file_name, filter, processors)
}
("projects", Some(sub_m)) => {
bartib::controller::list::list_projects(file_name, sub_m.is_present("current"))
@ -324,6 +336,16 @@ fn run_subcommand(matches: &ArgMatches, file_name: &str) -> Result<()> {
}
}
fn create_processors_for_arguments(sub_m: &ArgMatches) -> processor::ProcessorList {
let mut processors: Vec<Box<dyn processor::ActivityProcessor>> = Vec::new();
if let Some(round) = get_duration_argument_or_ignore(sub_m.value_of("round"), "--round") {
processors.push(Box::new(processor::RoundProcessor { round }));
}
processors
}
fn create_filter_for_arguments<'a>(sub_m: &'a ArgMatches) -> ActivityFilter<'a> {
let mut filter = ActivityFilter {
number_of_activities: get_number_argument_or_ignore(
@ -432,3 +454,31 @@ fn get_time_argument_or_ignore(
None
}
}
fn get_duration_argument_or_ignore(
duration_argument: Option<&str>,
argument_name: &str,
) -> Option<chrono::Duration> {
if let Some(duration_string) = duration_argument {
// extract last character, leave the rest as number
let (number_string, duration_unit) = duration_string.split_at(duration_string.len() - 1);
let number: Option<i64> = number_string.parse().ok();
match number {
Some(number) => match duration_unit {
"m" => Some(chrono::Duration::minutes(number)),
"h" => Some(chrono::Duration::hours(number)),
_ => {
println!(
"Can not parse \"{duration_string}\" as duration. Argument for {argument_name} is ignored"
);
None
}
},
None => None,
}
} else {
None
}
}