diff --git a/Cargo.lock b/Cargo.lock index 4fdc7c4..e66d302 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 = "0.3.5" +version = "2.1.4" dependencies = [ "anyhow", - "atty", "chrono", "clap", + "regex", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f020b22..74de412 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "time-track" -version = "0.3.5" +version = "2.1.4" 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/README.md b/README.md new file mode 100644 index 0000000..bbcf4bc --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# 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). + +``` +❯ 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 +``` diff --git a/src/args.rs b/src/args.rs new file mode 100644 index 0000000..e24e8c3 --- /dev/null +++ b/src/args.rs @@ -0,0 +1,19 @@ +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`) + /// 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)] + 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 70cd406..19dc159 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,140 +1,65 @@ -use std::io::{stdin, BufRead}; +use std::io::{self, BufRead}; +use chrono::{DateTime, Local}; -use chrono::{NaiveTime, Duration, Local}; use clap::Parser; -use atty::Stream; -use anyhow::Result; +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, -} -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) -> 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 mut second = parse_time(&cleaned)?; - if second < first { - second = second + Duration::hours(12); - } - durations.push(second - first); - seen = None; - } else { - seen = Some(cleaned.to_string()); - } - } - if let Some(unpaired) = seen { - println!("Ended with an open span from {unpaired}... Ignoring"); - } - return Ok(durations); -} - -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 { - if now < *previous { - now = now + Duration::hours(12); - } - 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(); - if now < unpaired { - now = now + Duration::hours(12); - } - durations.push(now - unpaired); - } - return Ok(durations); -} - -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())?; - 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(8) + Duration::minutes(break_duration); - return Ok(start + work_time); -} - -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); - } - - if args.mode == Modes::Prediction { - if let Ok(prediction) = get_prediction() { - println!("Your work will end at {}", prediction); + let stdin = io::stdin(); + let hours = args.hours.unwrap_or( + if args.discount { + 0 } else { - println!("Something went wrong..."); + 8 } - 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")), + let target_minutes = if args.discount { + (8 * 60) - ((hours * 60) + args.minutes) + } else { + hours * 60 + args.minutes }; - - 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", - }; - - println!("You have been working for {hours} {pluralized_hours} and {minutes} {pluralized_minutes}"); - }, - Err(err) => eprintln!("{}\nExiting...", err), + 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() { + 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, lines.iter())?; + + let mut total_minutes: i64 = 0; + let mut first: Option> = None; + let mut last: Option> = None; + for time in times { + match first { + None => { + first = Some(time); + }, + Some(prev) => { + total_minutes += (time - prev).num_minutes(); + first = None; + last = Some(time); + } + } + } + + if let Some(remaining) = first { + let now = Local::now(); + let now_str = now.format("%-I:%M %p"); + println!("Ended with unclosed span... assuming ending now: {}", now_str); + total_minutes += (now - remaining).num_minutes(); + } + + println!("{}", time::get_charaterized_time_remaining(total_minutes, target_minutes, last.unwrap_or_else(|| Local::now()))); + Ok(()) } diff --git a/src/modes.rs b/src/modes.rs deleted file mode 100644 index 17bb3a2..0000000 --- a/src/modes.rs +++ /dev/null @@ -1,40 +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, - /// 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) - } -} diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..2b31d50 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,89 @@ +use chrono::{Duration, Local, DateTime}; +use anyhow::{Result, anyhow}; +use std::iter::Iterator; +use regex::Regex; + +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()?; + 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; + } + #[allow(deprecated)] + Ok(date.date().and_hms_opt(hrs, mins, 0).ok_or(anyhow!(format!("{}:{:0>2} not a real time", hrs, mins)))?) +} + +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)?); + } + return Ok(durations); +} + +pub 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}"); +} + +pub 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, + ended_at: DateTime, +) -> 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 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 { + 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) + } + } +}