Massively rework the time tracker, mainly to support times across midnight

This commit is contained in:
Campbell Alden 2024-11-07 15:48:37 +09:00
parent b6df8af541
commit a11d5c0f5d
6 changed files with 170 additions and 236 deletions

70
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"
@ -72,17 +81,6 @@ 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 = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854" 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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.1.0" version = "1.1.0"
@ -208,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"
@ -252,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",
] ]
@ -284,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"
@ -317,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"
@ -360,12 +384,12 @@ dependencies = [
[[package]] [[package]]
name = "time-track" name = "time-track"
version = "1.1.0" version = "2.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"atty",
"chrono", "chrono",
"clap", "clap",
"regex",
] ]
[[package]] [[package]]

View file

@ -1,12 +1,12 @@
[package] [package]
name = "time-track" name = "time-track"
version = "1.1.0" version = "2.0.0"
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]
anyhow = "1.0.72" anyhow = "1.0.72"
atty = "0.2.14"
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"

14
src/args.rs Normal file
View file

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

View file

@ -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 clap::Parser;
use atty::Stream;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
mod args;
mod time;
mod modes; use args::Args;
use modes::Modes;
/// A simple program to track time spans fn main() -> Result<()> {
#[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<NaiveDateTime> {
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<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?.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<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 {
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() {
let args = Args::parse(); let args = Args::parse();
let is_terminal = atty::is(Stream::Stdin); println!("{:?}", args);
let stdin = io::stdin();
if !is_terminal && !args.mode.supports_piped_input() { let mut lines: Vec<String> = vec![];
panic!("Cannot run {0} mode on piped input", args.mode); for line in stdin.lock().lines() {
lines.push(line.expect("Issues when reading from stdin"));
} }
let maybe_durations = match args.mode { let midnight = Local::now().date().and_hms_opt(0, 0, 0).ok_or(anyhow!("Expected midnight to exist"))?;
Modes::TimeTable => all_at_once(is_terminal), let times = time::from_stream(&midnight.date(), lines.iter())?;
Modes::Live => live_spans(),
};
match maybe_durations { let mut total_minutes: i64 = 0;
Ok(durations) => { let mut first: Option<DateTime<Local>> = None;
let total_minutes: i64 = durations.iter().map(|d| { d.num_minutes() }).sum(); for time in times {
let (hours, minutes) = to_hrs_minutes(total_minutes); match first {
None => {
println!("-----------------"); first = Some(time);
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), 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(())
} }

View file

@ -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)
}
}

72
src/time.rs Normal file
View file

@ -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<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;
}
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<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);
}
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)
}
}