diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..82b2b9e --- /dev/null +++ b/.envrc @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# the shebang is ignored, but nice for editors + +if type -P lorri &>/dev/null; then + eval "$(lorri direnv)" +else + echo 'while direnv evaluated .envrc, could not find the command "lorri" [https://github.com/nix-community/lorri]' + use nix +fi diff --git a/Cargo.lock b/Cargo.lock index 8a761a6..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" @@ -17,12 +26,73 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is-terminal", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd" + +[[package]] +name = "anstyle-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" + [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "bitflags" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" + [[package]] name = "bumpalo" version = "3.13.0" @@ -56,12 +126,92 @@ dependencies = [ "winapi", ] +[[package]] +name = "clap" +version = "4.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1640e5cc7fb47dbb8338fd471b105e7ed6c3cb2aeb00c2e067127ffd3764a05d" +dependencies = [ + "clap_builder", + "clap_derive", + "once_cell", +] + +[[package]] +name = "clap_builder" +version = "4.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98c59138d527eeaf9b53f35a77fcc1fad9d883116070c63d5de1c7dc7b00c72b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8cd2b2a819ad6eec39e8f1d6b53001af1e5469f8c177579cdaeb313115b825f" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + [[package]] name = "core-foundation-sys" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +[[package]] +name = "errno" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" + [[package]] name = "iana-time-zone" version = "0.1.57" @@ -85,6 +235,17 @@ dependencies = [ "cc", ] +[[package]] +name = "is-terminal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +dependencies = [ + "hermit-abi", + "rustix", + "windows-sys", +] + [[package]] name = "js-sys" version = "0.3.64" @@ -100,12 +261,24 @@ version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +[[package]] +name = "linux-raw-sys" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" + [[package]] name = "log" 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" @@ -139,6 +312,54 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac5ffa1efe7548069688cd7028f32591853cd7b5b756d41bcffd2353e4fc75b4" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "syn" version = "2.0.23" @@ -163,9 +384,12 @@ dependencies = [ [[package]] name = "time-track" -version = "0.1.0" +version = "2.1.4" dependencies = [ + "anyhow", "chrono", + "clap", + "regex", ] [[package]] @@ -174,6 +398,12 @@ version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22049a19f4a68748a168c0fc439f9516686aa045927ff767eca0a85101fb6e73" +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + [[package]] name = "wasi" version = "0.10.0+wasi-snapshot-preview1" @@ -265,6 +495,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-targets" version = "0.48.1" diff --git a/Cargo.toml b/Cargo.toml index d0ed88f..74de412 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,12 @@ [package] name = "time-track" -version = "0.1.0" +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" 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/shell.nix b/shell.nix new file mode 100644 index 0000000..157b677 --- /dev/null +++ b/shell.nix @@ -0,0 +1,8 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + buildInputs = [ + pkgs.rustc + pkgs.cargo + ] ++ [ pkgs.libiconv pkgs.darwin.apple_sdk.frameworks.CoreServices ]; +} 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 3ed32dd..19dc159 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,35 +1,65 @@ -use std::io::{stdin, Read}; -use chrono::{NaiveTime, Duration}; +use std::io::{self, BufRead}; +use chrono::{DateTime, Local}; -fn read_times() -> Vec { - println!("Write all times separated by newlines and finish with CTRL+D\n"); - let mut times = String::new(); - stdin().read_to_string(&mut times).expect("An error occurred reading a line"); - times.split("\n") - .map(|s| { s.trim().to_string() }) - .collect() -} +use clap::Parser; +use anyhow::{anyhow, Result}; +mod args; +mod time; -fn parse_time(time_str: &str) -> NaiveTime { - NaiveTime::parse_from_str(time_str, "%H:%M") - .expect(&format!("Failed to parse time from {time_str}")) -} +use args::Args; -fn main() { - let times = read_times(); - let mut durations = vec![]; - for i in (0..times.len() - 1).step_by(2) { - let first = parse_time(times.get(i).unwrap()); - let mut second = parse_time(times.get(i + 1).unwrap()); - if second < first { - second = second + Duration::hours(12); +fn main() -> Result<()> { + let args = Args::parse(); + let stdin = io::stdin(); + let hours = args.hours.unwrap_or( + if args.discount { + 0 + } else { + 8 } - durations.push(second - first); + ); + + 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")); } - let total_minutes: i64 = durations.iter().map(|d| { d.num_minutes() }).sum(); - let minutes = total_minutes % 60; - let hours = total_minutes / 60; + // 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())?; - println!("You have been working for {hours} hour(s) and {minutes} minute(s)"); + 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/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) + } + } +}