From 98d5fbe232140a39be14ce1bf28451e4711ca455 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Fri, 14 Jul 2023 18:09:47 +0900 Subject: [PATCH 01/22] Add a prediction mode --- src/main.rs | 42 +++++++++++++++++++++++++++++++----------- src/modes.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 src/modes.rs diff --git a/src/main.rs b/src/main.rs index 30455b7..d07eb86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,16 +4,17 @@ use chrono::{NaiveTime, Duration, Local}; use clap::Parser; use atty::Stream; +mod modes; +use modes::Modes; + /// A simple program to track time spans #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { - /// When passed the script will collect time spans live. Close a span by pressing ENTER and - /// finish the calulation by passing in an EOF character - #[arg(short, long, default_value_t = false)] - live: bool, + /// Specify how the program should behave + #[clap(value_enum, default_value_t = Modes::default())] + mode: Modes, } - fn parse_time(time_str: &str) -> NaiveTime { NaiveTime::parse_from_str(time_str, "%H:%M") .expect(&format!("Failed to parse time from {time_str}")) @@ -75,18 +76,37 @@ fn live_spans() -> Vec { return durations; } +fn get_prediction() -> NaiveTime { + println!("Calculating an end time prediction...\nWhen did you start?\n"); + let mut start_time = String::new(); + let _ = stdin().read_line(&mut start_time); + let start = parse_time(start_time.trim()); + println!("Started at {0}", start.format("%H:%M")); + println!("How many minutes were you on break?\n"); + let mut break_time = String::new(); + let _ = stdin().read_line(&mut break_time); + let break_duration = break_time.trim().parse::().unwrap(); + let work_time = Duration::hours(8) + Duration::minutes(break_duration); + return start + work_time; +} + fn main() { let args = Args::parse(); let is_terminal = atty::is(Stream::Stdin); - if args.live && !is_terminal { - panic!("Cannot run live mode on piped input"); + if !is_terminal && !args.mode.supports_piped_input() { + panic!("Cannot run {0} mode on piped input", args.mode); } - let durations = if args.live { - live_spans() - } else { - all_at_once(is_terminal) + if args.mode == Modes::Prediction { + println!("Your work will end at {}", get_prediction()); + return; + } + + let durations = match args.mode { + Modes::TimeTable => all_at_once(is_terminal), + Modes::Live => live_spans(), + Modes::Prediction => panic!("Should have already returned before this"), }; let total_minutes: i64 = durations.iter().map(|d| { d.num_minutes() }).sum(); diff --git a/src/modes.rs b/src/modes.rs new file mode 100644 index 0000000..17bb3a2 --- /dev/null +++ b/src/modes.rs @@ -0,0 +1,40 @@ +use clap::ValueEnum; + +#[derive(ValueEnum, Debug, Copy, Clone, Eq, PartialEq)] +pub enum Modes { + /// Perform calculations by treating stdin as a stream of time pairs separated by newlines + TimeTable, + /// Perform live tracking of the user's time until an EOF character is received. + Live, + /// Predict a user's day end time (assuming a 9 hour work day) based on the user's start time + /// and the duration of a user's break. + Prediction, +} + +impl Modes { + /// Determine whether or not the variant supports piped input vs being used as a CLI tool + /// directly by a human + pub fn supports_piped_input(&self) -> bool { + match self { + Modes::TimeTable => true, + Modes::Live => false, + Modes::Prediction => false, + } + } +} + + +impl Default for Modes { + fn default() -> Self { + Self::TimeTable + } +} + +impl std::fmt::Display for Modes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.to_possible_value() + .expect("no values are skipped") + .get_name() + .fmt(f) + } +} From 1f532ead7f39cfd9a8cc8b9571e30ba10d53528b Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Fri, 14 Jul 2023 18:10:37 +0900 Subject: [PATCH 02/22] Bump to version v0.3.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a413cef..8d53a30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -354,7 +354,7 @@ dependencies = [ [[package]] name = "time-track" -version = "0.2.2" +version = "0.3.0" dependencies = [ "atty", "chrono", diff --git a/Cargo.toml b/Cargo.toml index ed15e7e..11a9b46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "0.2.2" +version = "0.3.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html From 7d90764cbde99d4a3ca8ab24a3a99cf92a8bd497 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Wed, 26 Jul 2023 15:01:12 +0900 Subject: [PATCH 03/22] Close unpaired spans on CTRL+D in live mode --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 7 ++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d53a30..37ccdeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -354,7 +354,7 @@ dependencies = [ [[package]] name = "time-track" -version = "0.3.0" +version = "0.3.1" dependencies = [ "atty", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 11a9b46..077c2e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "0.3.0" +version = "0.3.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/main.rs b/src/main.rs index d07eb86..28fc149 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,7 +71,12 @@ fn live_spans() -> Vec { } } if let Some(unpaired) = seen { - println!("Ended with an open span from {unpaired}... Ignoring"); + println!("Closing unpaired span now"); + let mut now = Local::now().naive_local().time(); + if now < unpaired { + now = now + Duration::hours(12); + } + durations.push(now - unpaired); } return durations; } From fc07ffb480a8d346a4e3de872f77cc6671a7afd1 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Wed, 26 Jul 2023 15:40:31 +0900 Subject: [PATCH 04/22] Collect up errors and report them instead of panicing --- Cargo.lock | 9 ++++++++- Cargo.toml | 3 ++- src/main.rs | 52 +++++++++++++++++++++++++++++++--------------------- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37ccdeb..4ccacc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,6 +66,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "anyhow" +version = "1.0.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" + [[package]] name = "atty" version = "0.2.14" @@ -354,8 +360,9 @@ dependencies = [ [[package]] name = "time-track" -version = "0.3.1" +version = "0.3.2" dependencies = [ + "anyhow", "atty", "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 077c2e7..880c8e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "time-track" -version = "0.3.1" +version = "0.3.2" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +anyhow = "1.0.72" atty = "0.2.14" chrono = "0.4.26" clap = { version = "4.3.11", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index 28fc149..2a9e7ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use std::io::{stdin, BufRead}; use chrono::{NaiveTime, Duration, Local}; use clap::Parser; use atty::Stream; +use anyhow::Result; mod modes; use modes::Modes; @@ -15,12 +16,12 @@ struct Args { #[clap(value_enum, default_value_t = Modes::default())] mode: Modes, } -fn parse_time(time_str: &str) -> NaiveTime { - NaiveTime::parse_from_str(time_str, "%H:%M") - .expect(&format!("Failed to parse time from {time_str}")) +fn parse_time(time_str: &str) -> Result { + let time = NaiveTime::parse_from_str(time_str, "%H:%M")?; + Ok(time) } -fn all_at_once(is_terminal: bool) -> Vec { +fn all_at_once(is_terminal: bool) -> Result> { if is_terminal { println!("Enter simple times one per line. Send an EOF character to sum all time spans"); } @@ -28,10 +29,10 @@ fn all_at_once(is_terminal: bool) -> Vec { let mut durations = vec![]; let mut seen: Option = None; for line in stdin().lock().lines() { - let cleaned = line.expect("failed to read a line").trim().to_string(); + let cleaned = line?.trim().to_string(); if let Some(previous) = &seen { - let first = parse_time(previous); - let mut second = parse_time(&cleaned); + let first = parse_time(previous)?; + let mut second = parse_time(&cleaned)?; if second < first { second = second + Duration::hours(12); } @@ -44,10 +45,10 @@ fn all_at_once(is_terminal: bool) -> Vec { if let Some(unpaired) = seen { println!("Ended with an open span from {unpaired}... Ignoring"); } - return durations; + return Ok(durations); } -fn live_spans() -> Vec { +fn live_spans() -> Result> { println!("Tracking Spans Live. Press ENTER to start a span\n"); let mut durations = vec![]; let mut seen: Option = None; @@ -78,21 +79,21 @@ fn live_spans() -> Vec { } durations.push(now - unpaired); } - return durations; + return Ok(durations); } -fn get_prediction() -> NaiveTime { +fn get_prediction() -> Result { println!("Calculating an end time prediction...\nWhen did you start?\n"); let mut start_time = String::new(); let _ = stdin().read_line(&mut start_time); - let start = parse_time(start_time.trim()); + let start = parse_time(start_time.trim())?; println!("Started at {0}", start.format("%H:%M")); println!("How many minutes were you on break?\n"); let mut break_time = String::new(); let _ = stdin().read_line(&mut break_time); - let break_duration = break_time.trim().parse::().unwrap(); + let break_duration = break_time.trim().parse::()?; let work_time = Duration::hours(8) + Duration::minutes(break_duration); - return start + work_time; + return Ok(start + work_time); } fn main() { @@ -104,19 +105,28 @@ fn main() { } if args.mode == Modes::Prediction { - println!("Your work will end at {}", get_prediction()); + if let Ok(prediction) = get_prediction() { + println!("Your work will end at {}", prediction); + } else { + println!("Something went wrong..."); + } return; } - let durations = match args.mode { + let maybe_durations = match args.mode { Modes::TimeTable => all_at_once(is_terminal), Modes::Live => live_spans(), - Modes::Prediction => panic!("Should have already returned before this"), + Modes::Prediction => Err(anyhow::anyhow!("Should have already returned before this")), }; - let total_minutes: i64 = durations.iter().map(|d| { d.num_minutes() }).sum(); - let minutes = total_minutes % 60; - let hours = total_minutes / 60; + match maybe_durations { + Ok(durations) => { + let total_minutes: i64 = durations.iter().map(|d| { d.num_minutes() }).sum(); + let minutes = total_minutes % 60; + let hours = total_minutes / 60; - println!("You have been working for {hours} hour(s) and {minutes} minute(s)"); + println!("You have been working for {hours} hour(s) and {minutes} minute(s)"); + }, + Err(err) => eprintln!("{}\nExiting...", err), + } } From df02ab447b4d6e94901508961aee6d631742cf67 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Tue, 16 Jan 2024 17:39:25 +0900 Subject: [PATCH 05/22] Handle hours and minutes pluralization --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ccacc4..810335e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,7 +360,7 @@ dependencies = [ [[package]] name = "time-track" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "atty", diff --git a/Cargo.toml b/Cargo.toml index 880c8e4..231bb42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "0.3.2" +version = "0.3.3" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/main.rs b/src/main.rs index 2a9e7ed..70cd406 100644 --- a/src/main.rs +++ b/src/main.rs @@ -124,8 +124,16 @@ fn main() { let total_minutes: i64 = durations.iter().map(|d| { d.num_minutes() }).sum(); let minutes = total_minutes % 60; let hours = total_minutes / 60; + let pluralized_hours = match hours { + 1 => "hour", + _ => "hours", + }; + let pluralized_minutes = match minutes { + 1 => "minute", + _ => "minutes", + }; - println!("You have been working for {hours} hour(s) and {minutes} minute(s)"); + println!("You have been working for {hours} {pluralized_hours} and {minutes} {pluralized_minutes}"); }, Err(err) => eprintln!("{}\nExiting...", err), } From fb725bbaeeda236e2778bed535bd054a6ef817df Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Tue, 16 Jan 2024 17:53:55 +0900 Subject: [PATCH 06/22] Arbitrary bump to v0.3.4 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 231bb42..1585a60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "0.3.3" +version = "0.3.4" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html From 6137e6c0464376549efd7f4db26e5c25a1f1ebb3 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Wed, 17 Jan 2024 10:10:13 +0900 Subject: [PATCH 07/22] bump version to 0.3.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 810335e..4fdc7c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,7 +360,7 @@ dependencies = [ [[package]] name = "time-track" -version = "0.3.3" +version = "0.3.5" dependencies = [ "anyhow", "atty", diff --git a/Cargo.toml b/Cargo.toml index 1585a60..f020b22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "0.3.4" +version = "0.3.5" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html From 283876ebdae0dbb1187450e04eae4527122dce5b Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Wed, 8 May 2024 11:37:47 +0900 Subject: [PATCH 08/22] Add comments and support for wrapping to a new day --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 47 +++++++++++++++++++++++++++++++++++++---------- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4fdc7c4..c7d9595 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,7 +360,7 @@ dependencies = [ [[package]] name = "time-track" -version = "0.3.5" +version = "0.4.0" dependencies = [ "anyhow", "atty", diff --git a/Cargo.toml b/Cargo.toml index f020b22..2a0eb97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "0.3.5" +version = "0.4.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/main.rs b/src/main.rs index 70cd406..9fefea1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,9 @@ use std::io::{stdin, BufRead}; -use chrono::{NaiveTime, Duration, Local}; +use chrono::{NaiveTime, Duration, Local, NaiveDateTime}; use clap::Parser; use atty::Stream; -use anyhow::Result; +use anyhow::{anyhow, Result}; mod modes; use modes::Modes; @@ -16,11 +16,23 @@ struct Args { #[clap(value_enum, default_value_t = Modes::default())] mode: Modes, } -fn parse_time(time_str: &str) -> Result { - let time = NaiveTime::parse_from_str(time_str, "%H:%M")?; - Ok(time) + +fn epoch() -> NaiveTime { + NaiveTime::from_hms_opt(0, 0, 0).expect("0, 0, 0 to be valid arguments to from_hms_opt") } +/// Given an arbitrary string, attempt to parse it as a naive time in the format HH:mm +fn parse_time(time_str: &str) -> Result { + let time = NaiveTime::parse_from_str(time_str, "%H:%M")?; + let naive_date = NaiveDateTime::from_timestamp_millis( + time.signed_duration_since(epoch()).num_milliseconds(), + ).ok_or(anyhow!("Should be able to make a datetime"))?; + Ok(naive_date) +} + +/// The default way of calculating time. Time values are given one per line and subsequent pairs of +/// lines are considered time spans. If an odd number of spans is given, then the final time value +/// is ignored. fn all_at_once(is_terminal: bool) -> Result> { if is_terminal { println!("Enter simple times one per line. Send an EOF character to sum all time spans"); @@ -33,8 +45,19 @@ fn all_at_once(is_terminal: bool) -> Result> { if let Some(previous) = &seen { let first = parse_time(previous)?; let mut second = parse_time(&cleaned)?; - if second < first { + + // The following logic attempts to handle wrapping from AM -> PM and also from PM -> + // AM but cannot handle multiple days in a single span + if (second < first) && first.time().signed_duration_since(epoch()).num_hours() < 12 { + // Assuming wrapped to PM + // first = 8:00 + // second: 1:00 second = second + Duration::hours(12); + } else if second < first { + // Wrapped to a new day because first was after noon. + // first = 14:40 + // second = 1:30 + second = second + Duration::hours(24) } durations.push(second - first); seen = None; @@ -48,6 +71,8 @@ fn all_at_once(is_terminal: bool) -> Result> { return Ok(durations); } +/// A "live" time tracker. Each time the user presses enter a new span is started or an existing +/// span is closed. The user may end the tracking at any time by sending an EOF command. fn live_spans() -> Result> { println!("Tracking Spans Live. Press ENTER to start a span\n"); let mut durations = vec![]; @@ -82,7 +107,9 @@ fn live_spans() -> Result> { return Ok(durations); } -fn get_prediction() -> Result { +/// Predict when the given amount of work has completed based on when the user started working and +/// how long they expect to be on break total. +fn get_prediction(hours: i64) -> Result { println!("Calculating an end time prediction...\nWhen did you start?\n"); let mut start_time = String::new(); let _ = stdin().read_line(&mut start_time); @@ -92,8 +119,8 @@ fn get_prediction() -> Result { let mut break_time = String::new(); let _ = stdin().read_line(&mut break_time); let break_duration = break_time.trim().parse::()?; - let work_time = Duration::hours(8) + Duration::minutes(break_duration); - return Ok(start + work_time); + let work_time = Duration::hours(hours) + Duration::minutes(break_duration); + return Ok(start.time() + work_time); } fn main() { @@ -105,7 +132,7 @@ fn main() { } if args.mode == Modes::Prediction { - if let Ok(prediction) = get_prediction() { + if let Ok(prediction) = get_prediction(8 /* hours */) { println!("Your work will end at {}", prediction); } else { println!("Something went wrong..."); From 088400680901f74fafd793b07ce9914e0dbcd1c0 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Tue, 10 Sep 2024 21:53:45 +0900 Subject: [PATCH 09/22] Consolidate 12 hr and 24 hr span adjustments into a helper --- src/main.rs | 43 ++++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9fefea1..320a231 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,24 @@ fn parse_time(time_str: &str) -> Result { Ok(naive_date) } +fn adjust_last(first: &NaiveTime, second: NaiveTime) -> NaiveTime { + // The following logic attempts to handle wrapping from AM -> PM and also from PM -> + // AM but cannot handle multiple days in a single span + if (second < *first) && first.signed_duration_since(epoch()).num_hours() < 12 { + // Assuming wrapped to PM + // first = 8:00 + // second: 1:00 + second + Duration::hours(12) + } else if second < *first { + // Wrapped to a new day because first was after noon. + // first = 14:40 + // second = 1:30 + second + Duration::hours(24) + } else { + second + } +} + /// The default way of calculating time. Time values are given one per line and subsequent pairs of /// lines are considered time spans. If an odd number of spans is given, then the final time value /// is ignored. @@ -44,22 +62,9 @@ fn all_at_once(is_terminal: bool) -> Result> { let cleaned = line?.trim().to_string(); if let Some(previous) = &seen { let first = parse_time(previous)?; - let mut second = parse_time(&cleaned)?; + let second = adjust_last(&first.time(), parse_time(&cleaned)?.time()); - // The following logic attempts to handle wrapping from AM -> PM and also from PM -> - // AM but cannot handle multiple days in a single span - if (second < first) && first.time().signed_duration_since(epoch()).num_hours() < 12 { - // Assuming wrapped to PM - // first = 8:00 - // second: 1:00 - second = second + Duration::hours(12); - } else if second < first { - // Wrapped to a new day because first was after noon. - // first = 14:40 - // second = 1:30 - second = second + Duration::hours(24) - } - durations.push(second - first); + durations.push(second - first.time()); seen = None; } else { seen = Some(cleaned.to_string()); @@ -80,9 +85,7 @@ fn live_spans() -> Result> { for _line in stdin().lock().lines() { let mut now = Local::now().naive_local().time(); if let Some(previous) = &seen { - if now < *previous { - now = now + Duration::hours(12); - } + now = adjust_last(previous, now); let prev_time = previous.format("%H:%M"); let now_time = now.format("%H:%M"); println!("Closing span from {prev_time} - {now_time}"); @@ -99,9 +102,7 @@ fn live_spans() -> Result> { if let Some(unpaired) = seen { println!("Closing unpaired span now"); let mut now = Local::now().naive_local().time(); - if now < unpaired { - now = now + Duration::hours(12); - } + now = adjust_last(&unpaired, now); durations.push(now - unpaired); } return Ok(durations); From 63861a8d326f46730539cd69d8fc7b0e5a57104c Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Tue, 10 Sep 2024 21:53:59 +0900 Subject: [PATCH 10/22] Close open ranges now instead of dropping them in the time-table mode --- src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 320a231..d8034bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,7 +71,10 @@ fn all_at_once(is_terminal: bool) -> Result> { } } if let Some(unpaired) = seen { - println!("Ended with an open span from {unpaired}... Ignoring"); + let now = Local::now().naive_local().time(); + let last = adjust_last(&now, parse_time(&unpaired)?.time()); + println!("Ended with an open span from {unpaired}... assuming now: {}", now.format("%H:%M")); + durations.push(now - last); } return Ok(durations); } From 259948ef1c39cda9206818a107d52f0ca2dd8e49 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Tue, 10 Sep 2024 21:55:16 +0900 Subject: [PATCH 11/22] Bump to version v0.4.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c7d9595..551ec04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,7 +360,7 @@ dependencies = [ [[package]] name = "time-track" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "atty", diff --git a/Cargo.toml b/Cargo.toml index 2a0eb97..2a67652 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "0.4.0" +version = "0.4.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html From ada5e10483e82789e2e5bbd7f6409f1d43e7e529 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Wed, 25 Sep 2024 00:12:09 +0900 Subject: [PATCH 12/22] Remove prediction mode and show more useful information in track mode --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 82 ++++++++++++++++++++++++++++++---------------------- src/modes.rs | 4 --- 4 files changed, 49 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 551ec04..27cf410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,7 +360,7 @@ dependencies = [ [[package]] name = "time-track" -version = "0.4.1" +version = "1.0.0" dependencies = [ "anyhow", "atty", diff --git a/Cargo.toml b/Cargo.toml index 2a67652..f633b2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "0.4.1" +version = "1.0.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/main.rs b/src/main.rs index d8034bd..5ee5ba2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,20 +111,49 @@ fn live_spans() -> Result> { return Ok(durations); } -/// Predict when the given amount of work has completed based on when the user started working and -/// how long they expect to be on break total. -fn get_prediction(hours: i64) -> Result { - println!("Calculating an end time prediction...\nWhen did you start?\n"); - let mut start_time = String::new(); - let _ = stdin().read_line(&mut start_time); - let start = parse_time(start_time.trim())?; - println!("Started at {0}", start.format("%H:%M")); - println!("How many minutes were you on break?\n"); - let mut break_time = String::new(); - let _ = stdin().read_line(&mut break_time); - let break_duration = break_time.trim().parse::()?; - let work_time = Duration::hours(hours) + Duration::minutes(break_duration); - return Ok(start.time() + work_time); +fn to_hrs_minutes(total_minutes: i64) -> (i64, i64) { + let minutes = total_minutes % 60; + let hours = total_minutes / 60; + (hours, minutes) +} + +fn show_time(hours: i64, minutes: i64) -> String { + let pluralized_hours = match hours { + 1 => "1 hour".to_string(), + _ => format!("{hours} hours"), + }; + let pluralized_minutes = match minutes { + 1 => "1 minute".to_string(), + _ => format!("{minutes} minutes"), + }; + + if hours == 0 { + return pluralized_minutes.to_string(); + } + + if minutes == 0 { + return pluralized_hours.to_string(); + } + + return format!("{pluralized_hours} and {pluralized_minutes}"); +} + +fn get_charaterized_time_remaining(total_minutes: i64, target_minutes: i64) -> String { + if total_minutes == target_minutes { + return "Exactly done".to_string(); + } + + if total_minutes > target_minutes { + let diff = total_minutes - target_minutes; + let (hours, minutes) = to_hrs_minutes(diff); + return format!("You have overworked {}", show_time(hours, minutes)) + } else { + let diff = target_minutes - total_minutes; + let (hours, minutes) = to_hrs_minutes(diff); + let end_at = (Local::now() + Duration::minutes(diff)).time(); + let end_str = end_at.format("%-I:%M %p"); + return format!("You have {} remaining (end at {} starting now)", show_time(hours, minutes), end_str) + } } fn main() { @@ -135,36 +164,19 @@ fn main() { panic!("Cannot run {0} mode on piped input", args.mode); } - if args.mode == Modes::Prediction { - if let Ok(prediction) = get_prediction(8 /* hours */) { - println!("Your work will end at {}", prediction); - } else { - println!("Something went wrong..."); - } - return; - } - let maybe_durations = match args.mode { Modes::TimeTable => all_at_once(is_terminal), Modes::Live => live_spans(), - Modes::Prediction => Err(anyhow::anyhow!("Should have already returned before this")), }; match maybe_durations { Ok(durations) => { let total_minutes: i64 = durations.iter().map(|d| { d.num_minutes() }).sum(); - let minutes = total_minutes % 60; - let hours = total_minutes / 60; - let pluralized_hours = match hours { - 1 => "hour", - _ => "hours", - }; - let pluralized_minutes = match minutes { - 1 => "minute", - _ => "minutes", - }; + let (hours, minutes) = to_hrs_minutes(total_minutes); - println!("You have been working for {hours} {pluralized_hours} and {minutes} {pluralized_minutes}"); + println!("-----------------"); + println!("You have been working for {}", show_time(hours, minutes)); + println!("{}", get_charaterized_time_remaining(total_minutes, 8 * 60)) }, Err(err) => eprintln!("{}\nExiting...", err), } diff --git a/src/modes.rs b/src/modes.rs index 17bb3a2..7aeeac2 100644 --- a/src/modes.rs +++ b/src/modes.rs @@ -6,9 +6,6 @@ pub enum Modes { TimeTable, /// Perform live tracking of the user's time until an EOF character is received. Live, - /// Predict a user's day end time (assuming a 9 hour work day) based on the user's start time - /// and the duration of a user's break. - Prediction, } impl Modes { @@ -18,7 +15,6 @@ impl Modes { match self { Modes::TimeTable => true, Modes::Live => false, - Modes::Prediction => false, } } } From b6df8af5417f465fc152035ea3e50b8528604800 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Mon, 21 Oct 2024 15:11:38 +0900 Subject: [PATCH 13/22] Add an option for how many hours you intend to work --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27cf410..0250120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,7 +360,7 @@ dependencies = [ [[package]] name = "time-track" -version = "1.0.0" +version = "1.1.0" dependencies = [ "anyhow", "atty", diff --git a/Cargo.toml b/Cargo.toml index f633b2d..bb70b58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "1.0.0" +version = "1.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/main.rs b/src/main.rs index 5ee5ba2..543711a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,9 @@ struct Args { /// Specify how the program should behave #[clap(value_enum, default_value_t = Modes::default())] mode: Modes, + + #[arg(long, default_value_t = 8)] + hours: i64, } fn epoch() -> NaiveTime { @@ -176,7 +179,7 @@ fn main() { println!("-----------------"); println!("You have been working for {}", show_time(hours, minutes)); - println!("{}", get_charaterized_time_remaining(total_minutes, 8 * 60)) + println!("{}", get_charaterized_time_remaining(total_minutes, args.hours * 60)) }, Err(err) => eprintln!("{}\nExiting...", err), } From a11d5c0f5ded70d8f274c0a0638eb6ebb7ec3efb Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Thu, 7 Nov 2024 15:48:37 +0900 Subject: [PATCH 14/22] Massively rework the time tracker, mainly to support times across midnight --- Cargo.lock | 70 +++++++++++------ Cargo.toml | 4 +- src/args.rs | 14 ++++ src/main.rs | 210 +++++++++------------------------------------------ src/modes.rs | 36 --------- src/time.rs | 72 ++++++++++++++++++ 6 files changed, 170 insertions(+), 236 deletions(-) create mode 100644 src/args.rs delete mode 100644 src/modes.rs create mode 100644 src/time.rs diff --git a/Cargo.lock b/Cargo.lock index 0250120..35bd882 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -72,17 +81,6 @@ version = "1.0.72" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi 0.1.19", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.1.0" @@ -208,15 +206,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - [[package]] name = "hermit-abi" version = "0.3.2" @@ -252,7 +241,7 @@ version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" dependencies = [ - "hermit-abi 0.3.2", + "hermit-abi", "rustix", "windows-sys", ] @@ -284,6 +273,12 @@ version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + [[package]] name = "num-traits" version = "0.2.15" @@ -317,6 +312,35 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "rustix" version = "0.38.3" @@ -360,12 +384,12 @@ dependencies = [ [[package]] name = "time-track" -version = "1.1.0" +version = "2.0.0" dependencies = [ "anyhow", - "atty", "chrono", "clap", + "regex", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bb70b58..5348706 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "time-track" -version = "1.1.0" +version = "2.0.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1.0.72" -atty = "0.2.14" chrono = "0.4.26" clap = { version = "4.3.11", features = ["derive"] } +regex = "1.11.1" diff --git a/src/args.rs b/src/args.rs new file mode 100644 index 0000000..abee82c --- /dev/null +++ b/src/args.rs @@ -0,0 +1,14 @@ +use clap::Parser; + +/// A simple program to track time spans and calculate remaining hours to work +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +pub struct Args { + /// How many hours you intend to work (sums with `minutes`) + #[arg(long, default_value_t = 8)] + pub hours: i64, + + /// How many minutes you intend to work (sums with `hours`) + #[arg(long, default_value_t = 0)] + pub minutes: i64, +} diff --git a/src/main.rs b/src/main.rs index 543711a..7541cd5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,186 +1,46 @@ -use std::io::{stdin, BufRead}; +use std::io::{self, BufRead}; +use chrono::{DateTime, Local}; -use chrono::{NaiveTime, Duration, Local, NaiveDateTime}; use clap::Parser; -use atty::Stream; use anyhow::{anyhow, Result}; +mod args; +mod time; -mod modes; -use modes::Modes; +use args::Args; -/// A simple program to track time spans -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -struct Args { - /// Specify how the program should behave - #[clap(value_enum, default_value_t = Modes::default())] - mode: Modes, - - #[arg(long, default_value_t = 8)] - hours: i64, -} - -fn epoch() -> NaiveTime { - NaiveTime::from_hms_opt(0, 0, 0).expect("0, 0, 0 to be valid arguments to from_hms_opt") -} - -/// Given an arbitrary string, attempt to parse it as a naive time in the format HH:mm -fn parse_time(time_str: &str) -> Result { - let time = NaiveTime::parse_from_str(time_str, "%H:%M")?; - let naive_date = NaiveDateTime::from_timestamp_millis( - time.signed_duration_since(epoch()).num_milliseconds(), - ).ok_or(anyhow!("Should be able to make a datetime"))?; - Ok(naive_date) -} - -fn adjust_last(first: &NaiveTime, second: NaiveTime) -> NaiveTime { - // The following logic attempts to handle wrapping from AM -> PM and also from PM -> - // AM but cannot handle multiple days in a single span - if (second < *first) && first.signed_duration_since(epoch()).num_hours() < 12 { - // Assuming wrapped to PM - // first = 8:00 - // second: 1:00 - second + Duration::hours(12) - } else if second < *first { - // Wrapped to a new day because first was after noon. - // first = 14:40 - // second = 1:30 - second + Duration::hours(24) - } else { - second - } -} - -/// The default way of calculating time. Time values are given one per line and subsequent pairs of -/// lines are considered time spans. If an odd number of spans is given, then the final time value -/// is ignored. -fn all_at_once(is_terminal: bool) -> Result> { - if is_terminal { - println!("Enter simple times one per line. Send an EOF character to sum all time spans"); - } - - let mut durations = vec![]; - let mut seen: Option = None; - for line in stdin().lock().lines() { - let cleaned = line?.trim().to_string(); - if let Some(previous) = &seen { - let first = parse_time(previous)?; - let second = adjust_last(&first.time(), parse_time(&cleaned)?.time()); - - durations.push(second - first.time()); - seen = None; - } else { - seen = Some(cleaned.to_string()); - } - } - if let Some(unpaired) = seen { - let now = Local::now().naive_local().time(); - let last = adjust_last(&now, parse_time(&unpaired)?.time()); - println!("Ended with an open span from {unpaired}... assuming now: {}", now.format("%H:%M")); - durations.push(now - last); - } - return Ok(durations); -} - -/// A "live" time tracker. Each time the user presses enter a new span is started or an existing -/// span is closed. The user may end the tracking at any time by sending an EOF command. -fn live_spans() -> Result> { - println!("Tracking Spans Live. Press ENTER to start a span\n"); - let mut durations = vec![]; - let mut seen: Option = None; - for _line in stdin().lock().lines() { - let mut now = Local::now().naive_local().time(); - if let Some(previous) = &seen { - now = adjust_last(previous, now); - let prev_time = previous.format("%H:%M"); - let now_time = now.format("%H:%M"); - println!("Closing span from {prev_time} - {now_time}"); - println!("Status: Away"); - durations.push(now - *previous); - seen = None; - } else { - let now_time = now.format("%H:%M"); - println!("Starting span at {now_time}"); - println!("Status: Working"); - seen = Some(now); - } - } - if let Some(unpaired) = seen { - println!("Closing unpaired span now"); - let mut now = Local::now().naive_local().time(); - now = adjust_last(&unpaired, now); - durations.push(now - unpaired); - } - return Ok(durations); -} - -fn to_hrs_minutes(total_minutes: i64) -> (i64, i64) { - let minutes = total_minutes % 60; - let hours = total_minutes / 60; - (hours, minutes) -} - -fn show_time(hours: i64, minutes: i64) -> String { - let pluralized_hours = match hours { - 1 => "1 hour".to_string(), - _ => format!("{hours} hours"), - }; - let pluralized_minutes = match minutes { - 1 => "1 minute".to_string(), - _ => format!("{minutes} minutes"), - }; - - if hours == 0 { - return pluralized_minutes.to_string(); - } - - if minutes == 0 { - return pluralized_hours.to_string(); - } - - return format!("{pluralized_hours} and {pluralized_minutes}"); -} - -fn get_charaterized_time_remaining(total_minutes: i64, target_minutes: i64) -> String { - if total_minutes == target_minutes { - return "Exactly done".to_string(); - } - - if total_minutes > target_minutes { - let diff = total_minutes - target_minutes; - let (hours, minutes) = to_hrs_minutes(diff); - return format!("You have overworked {}", show_time(hours, minutes)) - } else { - let diff = target_minutes - total_minutes; - let (hours, minutes) = to_hrs_minutes(diff); - let end_at = (Local::now() + Duration::minutes(diff)).time(); - let end_str = end_at.format("%-I:%M %p"); - return format!("You have {} remaining (end at {} starting now)", show_time(hours, minutes), end_str) - } -} - -fn main() { +fn main() -> Result<()> { let args = Args::parse(); - let is_terminal = atty::is(Stream::Stdin); - - if !is_terminal && !args.mode.supports_piped_input() { - panic!("Cannot run {0} mode on piped input", args.mode); + println!("{:?}", args); + let stdin = io::stdin(); + let mut lines: Vec = vec![]; + for line in stdin.lock().lines() { + lines.push(line.expect("Issues when reading from stdin")); } - let maybe_durations = match args.mode { - Modes::TimeTable => all_at_once(is_terminal), - Modes::Live => live_spans(), - }; + let midnight = Local::now().date().and_hms_opt(0, 0, 0).ok_or(anyhow!("Expected midnight to exist"))?; + let times = time::from_stream(&midnight.date(), lines.iter())?; - match maybe_durations { - Ok(durations) => { - let total_minutes: i64 = durations.iter().map(|d| { d.num_minutes() }).sum(); - let (hours, minutes) = to_hrs_minutes(total_minutes); - - println!("-----------------"); - println!("You have been working for {}", show_time(hours, minutes)); - println!("{}", get_charaterized_time_remaining(total_minutes, args.hours * 60)) - }, - Err(err) => eprintln!("{}\nExiting...", err), + let mut total_minutes: i64 = 0; + let mut first: Option> = None; + for time in times { + match first { + None => { + first = Some(time); + }, + Some(prev) => { + total_minutes += (time - prev).num_minutes(); + first = None + } + } } + + if let Some(remaining) = first { + let now = Local::now(); + println!("Ended with unclosed span... assuming ending now: {}", now.time()); + total_minutes += (now - remaining).num_minutes(); + } + + let target_minutes = args.hours * 60 + args.minutes; + println!("{}", time::get_charaterized_time_remaining(total_minutes, target_minutes)); + Ok(()) } diff --git a/src/modes.rs b/src/modes.rs deleted file mode 100644 index 7aeeac2..0000000 --- a/src/modes.rs +++ /dev/null @@ -1,36 +0,0 @@ -use clap::ValueEnum; - -#[derive(ValueEnum, Debug, Copy, Clone, Eq, PartialEq)] -pub enum Modes { - /// Perform calculations by treating stdin as a stream of time pairs separated by newlines - TimeTable, - /// Perform live tracking of the user's time until an EOF character is received. - Live, -} - -impl Modes { - /// Determine whether or not the variant supports piped input vs being used as a CLI tool - /// directly by a human - pub fn supports_piped_input(&self) -> bool { - match self { - Modes::TimeTable => true, - Modes::Live => false, - } - } -} - - -impl Default for Modes { - fn default() -> Self { - Self::TimeTable - } -} - -impl std::fmt::Display for Modes { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.to_possible_value() - .expect("no values are skipped") - .get_name() - .fmt(f) - } -} diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..dd4cbd8 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,72 @@ +use chrono::{Duration, Local, DateTime, Date}; +use anyhow::{Result, anyhow}; +use std::iter::Iterator; +use regex::Regex; + +fn parse_datetime(reference_date: &Date, s: &str) -> Result> { + let re = Regex::new(r"^\s*([12]?\d):([012345]\d)\s*$")?; + let (_, [hrs, mins]) = re.captures(s).ok_or(anyhow!(format!("Failed to parse \"{s}\" as a time")))?.extract(); + let mins: u32 = mins.parse()?; + let mut hrs: u32 = hrs.parse()?; + let mut date = *reference_date; + + if hrs > 23 { + let num_days = hrs / 24; + date = *reference_date + Duration::days(num_days.into()); + hrs = hrs % 24; + } + Ok(date.and_hms_opt(hrs, mins, 0).ok_or(anyhow!(format!("{hrs}:{mins} not a real time")))?) +} + +pub fn from_stream<'a>(reference_date: &Date, stream: impl Iterator) -> Result>> { + let mut durations: Vec> = vec![]; + for val in stream { + durations.push(parse_datetime(reference_date, val)?); + } + return Ok(durations); +} + +fn show_time(hours: i64, minutes: i64) -> String { + let pluralized_hours = match hours { + 1 => "1 hour".to_string(), + _ => format!("{hours} hours"), + }; + let pluralized_minutes = match minutes { + 1 => "1 minute".to_string(), + _ => format!("{minutes} minutes"), + }; + + if hours == 0 { + return pluralized_minutes.to_string(); + } + + if minutes == 0 { + return pluralized_hours.to_string(); + } + + return format!("{pluralized_hours} and {pluralized_minutes}"); +} + +fn to_hrs_minutes(total_minutes: i64) -> (i64, i64) { + let minutes = total_minutes % 60; + let hours = total_minutes / 60; + (hours, minutes) +} + +pub fn get_charaterized_time_remaining(total_minutes: i64, target_minutes: i64) -> String { + if total_minutes == target_minutes { + return "Exactly done".to_string(); + } + + if total_minutes > target_minutes { + let diff = total_minutes - target_minutes; + let (hours, minutes) = to_hrs_minutes(diff); + return format!("You have overworked {}", show_time(hours, minutes)) + } else { + let diff = target_minutes - total_minutes; + let (hours, minutes) = to_hrs_minutes(diff); + let end_at = (Local::now() + Duration::minutes(diff)).time(); + let end_str = end_at.format("%-I:%M %p"); + return format!("You have {} remaining (end at {} starting now)", show_time(hours, minutes), end_str) + } +} From 27b9345377dcaedb4e9808fa717562223627b5f4 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Thu, 7 Nov 2024 21:54:04 +0900 Subject: [PATCH 15/22] Fix a few small issues with v2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 6 ++++-- src/time.rs | 9 +++++---- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 35bd882..5aee76c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,7 +384,7 @@ dependencies = [ [[package]] name = "time-track" -version = "2.0.0" +version = "2.0.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 5348706..f3dc1a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "2.0.0" +version = "2.0.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/main.rs b/src/main.rs index 7541cd5..945df04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,15 +10,17 @@ use args::Args; fn main() -> Result<()> { let args = Args::parse(); - println!("{:?}", args); let stdin = io::stdin(); + println!("Input times one per line. Send an EOF character to finish inputting..."); let mut lines: Vec = vec![]; for line in stdin.lock().lines() { lines.push(line.expect("Issues when reading from stdin")); } + // This is immeidately going to be turned back into a DateTime, which the doc says is fine + #[allow(deprecated)] let midnight = Local::now().date().and_hms_opt(0, 0, 0).ok_or(anyhow!("Expected midnight to exist"))?; - let times = time::from_stream(&midnight.date(), lines.iter())?; + let times = time::from_stream(&midnight, lines.iter())?; let mut total_minutes: i64 = 0; let mut first: Option> = None; diff --git a/src/time.rs b/src/time.rs index dd4cbd8..787041d 100644 --- a/src/time.rs +++ b/src/time.rs @@ -1,9 +1,9 @@ -use chrono::{Duration, Local, DateTime, Date}; +use chrono::{Duration, Local, DateTime}; use anyhow::{Result, anyhow}; use std::iter::Iterator; use regex::Regex; -fn parse_datetime(reference_date: &Date, s: &str) -> Result> { +fn parse_datetime(reference_date: &DateTime, s: &str) -> Result> { let re = Regex::new(r"^\s*([12]?\d):([012345]\d)\s*$")?; let (_, [hrs, mins]) = re.captures(s).ok_or(anyhow!(format!("Failed to parse \"{s}\" as a time")))?.extract(); let mins: u32 = mins.parse()?; @@ -15,10 +15,11 @@ fn parse_datetime(reference_date: &Date, s: &str) -> Result2} not a real time", hrs, mins)))?) } -pub fn from_stream<'a>(reference_date: &Date, stream: impl Iterator) -> Result>> { +pub fn from_stream<'a>(reference_date: &DateTime, stream: impl Iterator) -> Result>> { let mut durations: Vec> = vec![]; for val in stream { durations.push(parse_datetime(reference_date, val)?); From 7a6aa2489bc4ab8c0c01812421c01f3b2d646163 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Fri, 13 Dec 2024 18:11:48 +0900 Subject: [PATCH 16/22] Add the option to treat minutes and hours flags as discounting from an 8 hr day --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/args.rs | 4 ++++ src/main.rs | 6 +++++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5aee76c..52be8ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,7 +384,7 @@ dependencies = [ [[package]] name = "time-track" -version = "2.0.1" +version = "2.1.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index f3dc1a8..ed1f106 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "2.0.1" +version = "2.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/args.rs b/src/args.rs index abee82c..e4a0c2d 100644 --- a/src/args.rs +++ b/src/args.rs @@ -11,4 +11,8 @@ pub struct Args { /// How many minutes you intend to work (sums with `hours`) #[arg(long, default_value_t = 0)] pub minutes: i64, + + /// If true, the the hours and minutes fields are treated as subtracting from 8 hours + #[arg(long, default_value_t = false)] + pub discount: bool, } diff --git a/src/main.rs b/src/main.rs index 945df04..6539549 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,7 +42,11 @@ fn main() -> Result<()> { total_minutes += (now - remaining).num_minutes(); } - let target_minutes = args.hours * 60 + args.minutes; + let target_minutes = if args.discount { + (8 * 60) - (args.hours * 60 + args.minutes) + } else { + args.hours * 60 + args.minutes + }; println!("{}", time::get_charaterized_time_remaining(total_minutes, target_minutes)); Ok(()) } From b6d1c0ea0c801072cc8557586cf7d3d9cdf2eef8 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Fri, 13 Dec 2024 18:21:55 +0900 Subject: [PATCH 17/22] Change the default behavior for hours to 0 if discount is set --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/args.rs | 5 +++-- src/main.rs | 20 +++++++++++++++----- src/time.rs | 4 ++-- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52be8ab..f186d3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,7 +384,7 @@ dependencies = [ [[package]] name = "time-track" -version = "2.1.0" +version = "2.1.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index ed1f106..944e031 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "2.1.0" +version = "2.1.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/args.rs b/src/args.rs index e4a0c2d..e24e8c3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -5,8 +5,9 @@ use clap::Parser; #[command(author, version, about, long_about = None)] pub struct Args { /// How many hours you intend to work (sums with `minutes`) - #[arg(long, default_value_t = 8)] - pub hours: i64, + /// Usually defaults to 8 hours, but if discount is set then it defaults to 0 + #[arg(long)] + pub hours: Option, /// How many minutes you intend to work (sums with `hours`) #[arg(long, default_value_t = 0)] diff --git a/src/main.rs b/src/main.rs index 6539549..2963b9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,21 @@ use args::Args; fn main() -> Result<()> { let args = Args::parse(); let stdin = io::stdin(); + let hours = args.hours.unwrap_or( + if args.discount { + 0 + } else { + 8 + } + ); + + let target_minutes = if args.discount { + (8 * 60) - ((hours * 60) + args.minutes) + } else { + hours * 60 + args.minutes + }; + let (hrs, mins) = time::to_hrs_minutes(target_minutes); + println!("Working for {}", time::show_time(hrs, mins)); println!("Input times one per line. Send an EOF character to finish inputting..."); let mut lines: Vec = vec![]; for line in stdin.lock().lines() { @@ -42,11 +57,6 @@ fn main() -> Result<()> { total_minutes += (now - remaining).num_minutes(); } - let target_minutes = if args.discount { - (8 * 60) - (args.hours * 60 + args.minutes) - } else { - args.hours * 60 + args.minutes - }; println!("{}", time::get_charaterized_time_remaining(total_minutes, target_minutes)); Ok(()) } diff --git a/src/time.rs b/src/time.rs index 787041d..6d49767 100644 --- a/src/time.rs +++ b/src/time.rs @@ -27,7 +27,7 @@ pub fn from_stream<'a>(reference_date: &DateTime, stream: impl Iterator String { +pub fn show_time(hours: i64, minutes: i64) -> String { let pluralized_hours = match hours { 1 => "1 hour".to_string(), _ => format!("{hours} hours"), @@ -48,7 +48,7 @@ fn show_time(hours: i64, minutes: i64) -> String { return format!("{pluralized_hours} and {pluralized_minutes}"); } -fn to_hrs_minutes(total_minutes: i64) -> (i64, i64) { +pub fn to_hrs_minutes(total_minutes: i64) -> (i64, i64) { let minutes = total_minutes % 60; let hours = total_minutes / 60; (hours, minutes) From 9087e69c7902733c1939a5908ea3e3a799dd96de Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Mon, 23 Dec 2024 16:59:09 +0900 Subject: [PATCH 18/22] Simplify the now string --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f186d3f..db20599 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,7 +384,7 @@ dependencies = [ [[package]] name = "time-track" -version = "2.1.1" +version = "2.1.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 944e031..76bb2be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "2.1.1" +version = "2.1.2" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/main.rs b/src/main.rs index 2963b9e..4ea78a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,7 +53,8 @@ fn main() -> Result<()> { if let Some(remaining) = first { let now = Local::now(); - println!("Ended with unclosed span... assuming ending now: {}", now.time()); + let now_str = now.format("%-I:%M %p"); + println!("Ended with unclosed span... assuming ending now: {}", now_str); total_minutes += (now - remaining).num_minutes(); } From 2b55df907bc556774d1bd7538fdd24270262d567 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Thu, 26 Dec 2024 18:47:35 +0900 Subject: [PATCH 19/22] Count end time from the last time if the last time is in the future --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 6 ++++-- src/time.rs | 19 ++++++++++++++++--- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db20599..925ae63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,7 +384,7 @@ dependencies = [ [[package]] name = "time-track" -version = "2.1.2" +version = "2.1.3" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 76bb2be..e318688 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "2.1.2" +version = "2.1.3" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/main.rs b/src/main.rs index 4ea78a7..19dc159 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,7 @@ fn main() -> Result<()> { let mut total_minutes: i64 = 0; let mut first: Option> = None; + let mut last: Option> = None; for time in times { match first { None => { @@ -46,7 +47,8 @@ fn main() -> Result<()> { }, Some(prev) => { total_minutes += (time - prev).num_minutes(); - first = None + first = None; + last = Some(time); } } } @@ -58,6 +60,6 @@ fn main() -> Result<()> { total_minutes += (now - remaining).num_minutes(); } - println!("{}", time::get_charaterized_time_remaining(total_minutes, target_minutes)); + println!("{}", time::get_charaterized_time_remaining(total_minutes, target_minutes, last.unwrap_or_else(|| Local::now()))); Ok(()) } diff --git a/src/time.rs b/src/time.rs index 6d49767..eb0e0cf 100644 --- a/src/time.rs +++ b/src/time.rs @@ -54,7 +54,11 @@ pub fn to_hrs_minutes(total_minutes: i64) -> (i64, i64) { (hours, minutes) } -pub fn get_charaterized_time_remaining(total_minutes: i64, target_minutes: i64) -> String { +pub fn get_charaterized_time_remaining( + total_minutes: i64, + target_minutes: i64, + ended_at: DateTime, +) -> String { if total_minutes == target_minutes { return "Exactly done".to_string(); } @@ -66,8 +70,17 @@ pub fn get_charaterized_time_remaining(total_minutes: i64, target_minutes: i64) } else { let diff = target_minutes - total_minutes; let (hours, minutes) = to_hrs_minutes(diff); - let end_at = (Local::now() + Duration::minutes(diff)).time(); + let end_at = (ended_at + Duration::minutes(diff)).time(); let end_str = end_at.format("%-I:%M %p"); - return format!("You have {} remaining (end at {} starting now)", show_time(hours, minutes), end_str) + return if ended_at > Local::now() { + format!( + "You have {} remaining (end at {} starting from {})", + show_time(hours, minutes), + end_str, + ended_at.format("%-I:%M %p"), + ) + } else { + format!("You have {} remaining (end at {} starting now)", show_time(hours, minutes), end_str) + } } } From 51a200d1a3097264d0985f03905058efc69f9a0e Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Fri, 27 Dec 2024 09:36:49 +0900 Subject: [PATCH 20/22] Bug Fix: Count time from now if the end time is before now --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/time.rs | 23 +++++++++++++---------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 925ae63..e66d302 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,7 +384,7 @@ dependencies = [ [[package]] name = "time-track" -version = "2.1.3" +version = "2.1.4" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index e318688..74de412 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "time-track" -version = "2.1.3" +version = "2.1.4" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/time.rs b/src/time.rs index eb0e0cf..2b31d50 100644 --- a/src/time.rs +++ b/src/time.rs @@ -70,17 +70,20 @@ pub fn get_charaterized_time_remaining( } else { let diff = target_minutes - total_minutes; let (hours, minutes) = to_hrs_minutes(diff); - let end_at = (ended_at + Duration::minutes(diff)).time(); - let end_str = end_at.format("%-I:%M %p"); - return if ended_at > Local::now() { - format!( - "You have {} remaining (end at {} starting from {})", - show_time(hours, minutes), - end_str, - ended_at.format("%-I:%M %p"), - ) + let now = Local::now(); + return if ended_at > now { + let end_at = (ended_at + Duration::minutes(diff)).time(); + let end_str = end_at.format("%-I:%M %p"); + format!( + "You have {} remaining (end at {} starting from {})", + show_time(hours, minutes), + end_str, + ended_at.format("%-I:%M %p"), + ) } else { - format!("You have {} remaining (end at {} starting now)", show_time(hours, minutes), end_str) + let end_at = (now + Duration::minutes(diff)).time(); + let end_str = end_at.format("%-I:%M %p"); + format!("You have {} remaining (end at {} starting now)", show_time(hours, minutes), end_str) } } } From e2c0b08c032ecec8f27236f4f7132519472a165f Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Sun, 20 Apr 2025 22:11:19 +0900 Subject: [PATCH 21/22] Create README.md --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..21c3754 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# time-track + +A simple CLI tool for tracking how much time you have left to work. I find myself stressing about whether I'm hitting 8 real hours, so this little tool helps me avoid wasting +time calculating when my work day will end. + +Simply enter times, one per line and send an EOF character when you're done. The first lines opens a span of work and the next line closes it so that you can build up working +time be clocking in and out. Finally, you can send additional arguments to the program to configure how long you intend to work (the default is 8 hours). From 4a86e930f9120149a5cfcb9bace10d021cc888f6 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Sun, 20 Apr 2025 22:18:34 +0900 Subject: [PATCH 22/22] Update README.md --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 21c3754..bbcf4bc 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,18 @@ time calculating when my work day will end. Simply enter times, one per line and send an EOF character when you're done. The first lines opens a span of work and the next line closes it so that you can build up working time be clocking in and out. Finally, you can send additional arguments to the program to configure how long you intend to work (the default is 8 hours). + +``` +❯ time-track +Working for 8 hours +Input times one per line. Send an EOF character to finish inputting... +8:30 +9:30 +11:15 +12:30 +13:16 +18:20 +19:30 +20:11 # Send an EOF +Exactly done +```