Compare commits

...

23 commits
v0.2.1 ... main

Author SHA1 Message Date
Campbell Alden
4a86e930f9
Update README.md 2025-04-20 22:18:34 +09:00
Campbell Alden
e2c0b08c03
Create README.md 2025-04-20 22:11:19 +09:00
Campbell Alden
51a200d1a3 Bug Fix: Count time from now if the end time is before now 2024-12-27 09:36:49 +09:00
Campbell Alden
2b55df907b Count end time from the last time if the last time is in the future 2024-12-26 18:47:35 +09:00
Campbell Alden
9087e69c79 Simplify the now string 2024-12-23 16:59:09 +09:00
Campbell Alden
b6d1c0ea0c Change the default behavior for hours to 0 if discount is set 2024-12-13 18:22:22 +09:00
Campbell Alden
7a6aa2489b Add the option to treat minutes and hours flags as discounting from an 8 hr day 2024-12-13 18:11:48 +09:00
Campbell Alden
27b9345377 Fix a few small issues with v2 2024-11-07 21:54:04 +09:00
Campbell Alden
a11d5c0f5d Massively rework the time tracker, mainly to support times across midnight 2024-11-07 15:48:37 +09:00
Campbell Alden
b6df8af541 Add an option for how many hours you intend to work 2024-10-21 15:11:38 +09:00
Campbell Alden
ada5e10483 Remove prediction mode and show more useful information in track mode 2024-09-25 00:12:09 +09:00
Campbell Alden
259948ef1c Bump to version v0.4.1 2024-09-10 21:55:16 +09:00
Campbell Alden
63861a8d32 Close open ranges now instead of dropping them in the time-table mode 2024-09-10 21:53:59 +09:00
Campbell Alden
0884006809 Consolidate 12 hr and 24 hr span adjustments into a helper 2024-09-10 21:53:45 +09:00
Campbell Alden
283876ebda Add comments and support for wrapping to a new day 2024-05-08 11:37:47 +09:00
Campbell Alden
6137e6c046 bump version to 0.3.5 2024-01-17 10:10:13 +09:00
Campbell Alden
fb725bbaee Arbitrary bump to v0.3.4 2024-01-16 17:53:55 +09:00
Campbell Alden
df02ab447b Handle hours and minutes pluralization 2024-01-16 17:43:39 +09:00
Campbell Alden
fc07ffb480 Collect up errors and report them instead of panicing 2023-07-26 15:40:31 +09:00
Campbell Alden
7d90764cbd Close unpaired spans on CTRL+D in live mode 2023-07-26 15:01:12 +09:00
Campbell Alden
1f532ead7f Bump to version v0.3.0 2023-07-14 18:10:37 +09:00
Campbell Alden
98d5fbe232 Add a prediction mode 2023-07-14 18:09:47 +09:00
Campbell Alden
6b300b3d0a Tried bumping cargo versions to fix nix 2023-07-11 17:40:18 +09:00
6 changed files with 238 additions and 108 deletions

71
Cargo.lock generated
View file

@ -2,6 +2,15 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 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]] [[package]]
name = "android-tzdata" name = "android-tzdata"
version = "0.1.1" version = "0.1.1"
@ -67,15 +76,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "atty" name = "anyhow"
version = "0.2.14" version = "1.0.72"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854"
dependencies = [
"hermit-abi 0.1.19",
"libc",
"winapi",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
@ -202,15 +206,6 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 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]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.3.2" version = "0.3.2"
@ -246,7 +241,7 @@ version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b"
dependencies = [ dependencies = [
"hermit-abi 0.3.2", "hermit-abi",
"rustix", "rustix",
"windows-sys", "windows-sys",
] ]
@ -278,6 +273,12 @@ version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4"
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.15" version = "0.2.15"
@ -311,6 +312,35 @@ dependencies = [
"proc-macro2", "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]] [[package]]
name = "rustix" name = "rustix"
version = "0.38.3" version = "0.38.3"
@ -354,11 +384,12 @@ dependencies = [
[[package]] [[package]]
name = "time-track" name = "time-track"
version = "0.2.0" version = "2.1.4"
dependencies = [ dependencies = [
"atty", "anyhow",
"chrono", "chrono",
"clap", "clap",
"regex",
] ]
[[package]] [[package]]

View file

@ -1,11 +1,12 @@
[package] [package]
name = "time-track" name = "time-track"
version = "0.2.0" version = "2.1.4"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
atty = "0.2.14" anyhow = "1.0.72"
chrono = "0.4.26" chrono = "0.4.26"
clap = { version = "4.3.11", features = ["derive"] } clap = { version = "4.3.11", features = ["derive"] }
regex = "1.11.1"

22
README.md Normal file
View file

@ -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
```

19
src/args.rs Normal file
View file

@ -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<i64>,
/// 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,
}

View file

@ -1,97 +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 clap::Parser;
use atty::Stream; use anyhow::{anyhow, Result};
mod args;
mod time;
/// A simple program to track time spans use args::Args;
#[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,
}
fn parse_time(time_str: &str) -> NaiveTime { fn main() -> Result<()> {
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<Duration> {
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<String> = 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<Duration> {
println!("Tracking Spans Live. Press ENTER to start a span\n");
let mut durations = vec![];
let mut seen: Option<NaiveTime> = 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 main() {
let args = Args::parse(); let args = Args::parse();
let is_terminal = atty::is(Stream::Stdin); let stdin = io::stdin();
let hours = args.hours.unwrap_or(
if args.discount {
0
} else {
8
}
);
if args.live && !is_terminal { let target_minutes = if args.discount {
panic!("Cannot run live mode on piped input"); (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<String> = vec![];
for line in stdin.lock().lines() {
lines.push(line.expect("Issues when reading from stdin"));
} }
let durations = if args.live { // This is immeidately going to be turned back into a DateTime, which the doc says is fine
live_spans() #[allow(deprecated)]
} else { let midnight = Local::now().date().and_hms_opt(0, 0, 0).ok_or(anyhow!("Expected midnight to exist"))?;
all_at_once(is_terminal) let times = time::from_stream(&midnight, lines.iter())?;
};
let total_minutes: i64 = durations.iter().map(|d| { d.num_minutes() }).sum(); let mut total_minutes: i64 = 0;
let minutes = total_minutes % 60; let mut first: Option<DateTime<Local>> = None;
let hours = total_minutes / 60; let mut last: Option<DateTime<Local>> = None;
for time in times {
match first {
None => {
first = Some(time);
},
Some(prev) => {
total_minutes += (time - prev).num_minutes();
first = None;
last = Some(time);
}
}
}
println!("You have been working for {hours} hour(s) and {minutes} minute(s)"); 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(())
} }

89
src/time.rs Normal file
View file

@ -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<Local>, s: &str) -> Result<DateTime<Local>> {
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<Local>, stream: impl Iterator<Item = &'a String>) -> Result<Vec<DateTime<Local>>> {
let mut durations: Vec<DateTime<Local>> = 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<Local>,
) -> 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)
}
}
}