diff --git a/Cargo.lock b/Cargo.lock index e66d302..8d53a30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # 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" @@ -76,10 +67,15 @@ dependencies = [ ] [[package]] -name = "anyhow" -version = "1.0.72" +name = "atty" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] [[package]] name = "autocfg" @@ -206,6 +202,15 @@ 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" @@ -241,7 +246,7 @@ version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.2", "rustix", "windows-sys", ] @@ -273,12 +278,6 @@ 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" @@ -312,35 +311,6 @@ 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" @@ -384,12 +354,11 @@ dependencies = [ [[package]] name = "time-track" -version = "2.1.4" +version = "0.3.0" dependencies = [ - "anyhow", + "atty", "chrono", "clap", - "regex", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 74de412..11a9b46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,11 @@ [package] name = "time-track" -version = "2.1.4" +version = "0.3.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/README.md b/README.md deleted file mode 100644 index bbcf4bc..0000000 --- a/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index e24e8c3..0000000 --- a/src/args.rs +++ /dev/null @@ -1,19 +0,0 @@ -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 19dc159..d07eb86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,65 +1,117 @@ -use std::io::{self, BufRead}; -use chrono::{DateTime, Local}; +use std::io::{stdin, BufRead}; +use chrono::{NaiveTime, Duration, Local}; use clap::Parser; -use anyhow::{anyhow, Result}; -mod args; -mod time; +use atty::Stream; -use args::Args; +mod modes; +use modes::Modes; -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() { - 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(()) +/// 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) -> NaiveTime { + NaiveTime::parse_from_str(time_str, "%H:%M") + .expect(&format!("Failed to parse time from {time_str}")) +} + +fn all_at_once(is_terminal: bool) -> Vec { + 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.expect("failed to read a 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 durations; +} + +fn live_spans() -> Vec { + 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!("Ended with an open span from {unpaired}... Ignoring"); + } + 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 !is_terminal && !args.mode.supports_piped_input() { + panic!("Cannot run {0} mode on piped input", args.mode); + } + + 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(); + let minutes = total_minutes % 60; + let hours = total_minutes / 60; + + println!("You have been working for {hours} hour(s) and {minutes} minute(s)"); } 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) + } +} diff --git a/src/time.rs b/src/time.rs deleted file mode 100644 index 2b31d50..0000000 --- a/src/time.rs +++ /dev/null @@ -1,89 +0,0 @@ -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) - } - } -}