diff --git a/.gitignore b/.gitignore index c0aa01fe8dd24f56887cd250b8b9daa7adac7193..792f5e64d0774ca6e5b70762df9dbe4edde8345f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +/target *.db *.csv diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000000000000000000000000000000000000..a1ffd27ade2fee9c76d6f4f37ac0f726162bb1d0 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1 @@ +max_width = 79 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..8e734f32c5eb7fbad8370b8855ec6574a8ed6f63 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,511 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb833f0bf979d8475d38fbf09ed3b8a55e1885fe93ad3f93239fc6a4f17b98" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "ansi_term" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi", +] + +[[package]] +name = "anyhow" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61604a8f862e1d5c3229fdd78f8b02c68dcf73a4c4b05fd636d12240aaa242c1" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "serde", + "time", + "winapi", +] + +[[package]] +name = "clap" +version = "2.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +dependencies = [ + "ansi_term", + "atty", + "bitflags", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "csv" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" +dependencies = [ + "bstr", + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +dependencies = [ + "memchr", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "getrandom" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8f7255a17a627354f321ef0055d63b898c6fb27eff628af4d1b66b7331edf6" + +[[package]] +name = "libsqlite3-sys" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290b64917f8b0cb885d9de0f9959fe1f775d7fa12f1da2db9001c1c8ab60f89d" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "memchr" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" + +[[package]] +name = "pkg-config" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9b1041b4387893b91ee6746cddfc28516aff326a3519fb2adf820932c5e6cb" + +[[package]] +name = "prettytable-rs" +version = "0.8.0" +source = "git+https://github.com/wookietreiber/prettytable-rs?branch=wookieepatch#49282808555841a3b5b41867bf81524de116fca5" +dependencies = [ + "atty", + "csv", + "encode_unicode", + "lazy_static", + "term", + "unicode-width", +] + +[[package]] +name = "proc-macro2" +version = "1.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f5105d4fdaab20335ca9565e106a5d9b82b6219b5ba735731124ac6711d23d" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" +dependencies = [ + "getrandom", + "redox_syscall", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" + +[[package]] +name = "rusqlite" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57adcf67c8faaf96f3248c2a7b419a0dbc52ebe36ba83dd57fe83827c1ea4eb3" +dependencies = [ + "bitflags", + "chrono", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "memchr", + "smallvec", +] + +[[package]] +name = "rustversion" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b3909d758bb75c79f23d4736fac9433868679d3ad2ea7a61e3c25cfda9a088" + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "serde" +version = "1.0.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f690853975602e1bfe1ccbf50504d67174e3bcf340f23b5ea9992e0587a52d8" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "smallvec" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" + +[[package]] +name = "smooth" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "455f1ef596f9ea938a71ea5615bcd6be4148ea10e8892e665de6d70936b40248" + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "syn" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4eac2e6c19f5c3abc0c229bea31ff0b9b091c7b14990e8924b92902a303a0c0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "ttanalysis" +version = "0.1.0" +dependencies = [ + "anyhow", + "atty", + "chrono", + "clap", + "csv", + "prettytable-rs", + "rusqlite", + "serde", + "serde_json", + "smooth", +] + +[[package]] +name = "unicode-width" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..9e90278aa6625434a3f3e15e6d97f68ed609c71b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ttanalysis" +version = "0.1.0" +authors = ["Christian Krause <christian.krause@mailbox.org>"] +edition = "2018" + +[dependencies] +anyhow = "1" +atty = "0.2" +chrono = { version = "0.4", features = ["serde"] } +clap = "2" +csv = "1" +rusqlite = { version = "0.25", features = ["chrono"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +smooth = "0.1" + +[dependencies.prettytable-rs] +git = "https://github.com/wookietreiber/prettytable-rs" +branch = "wookieepatch" diff --git a/README.md b/README.md index a1d4e0314c4d0a0595b9c664172d274636fe177f..f65f674e3dbe4427f41f55262fed69c0a5ee3ee9 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,3 @@ # Time Tracking Analysis Analyze time tracking data. - -## Dependencies - -- JDK 8 -- SQLite 3 -- [Ammonite](http://www.lihaoyi.com/Ammonite/#Ammonite-REPL) - -## Usage - -1. Create a database for your **debit** work hours: - - ``` - sqlite3 timetracking.db 'create table debit (day text primary key, hours real)' - ``` - -2. Fill the database with your **debit** work hours: - - ``` - sqlite3 timetracking.db 'insert into debit (day, hours) values ("2017-03-28", 8)' - ``` - -3. Start analysis: - - ``` - amm ttanalysis.sc timetracking.db export_all.csv - ``` diff --git a/src/balance.rs b/src/balance.rs new file mode 100644 index 0000000000000000000000000000000000000000..0e77d6ea9f71e49a60ac9638f88bdd27e61f1610 --- /dev/null +++ b/src/balance.rs @@ -0,0 +1,104 @@ +use std::path::Path; + +use anyhow::Result; +use chrono::IsoWeek; +use prettytable::format::FormatBuilder; +use prettytable::{cell, row, Cell, Table}; +use smooth::{MultiSmooth, Smooth}; + +use crate::cli::Color; + +pub fn run<P1, P2>(gleeo: P1, sqlite: P2, color: Color) -> Result<()> +where + P1: AsRef<Path>, + P2: AsRef<Path>, +{ + let debit = crate::debit::all(sqlite)?.weekly(); + let credit = crate::credit::all(gleeo)?.weekly(); + + let mut weeks: Vec<IsoWeek> = vec![]; + weeks.extend(debit.keys()); + weeks.extend(credit.keys()); + weeks.sort_unstable(); + weeks.dedup(); + + let mut credit_weekly = Vec::with_capacity(weeks.len()); + let mut debit_weekly = Vec::with_capacity(weeks.len()); + let mut balance_weekly = Vec::with_capacity(weeks.len()); + let mut rolling_weekly = Vec::with_capacity(weeks.len()); + + let mut total_credit = f64::default(); + let mut total_debit = f64::default(); + let mut total_balance = f64::default(); + + for week in &weeks { + let credit = credit.get(week).copied().unwrap_or_default(); + + // ALLOW time-tracked entries won't be too large + #[allow(clippy::cast_precision_loss)] + let credit = (credit as f64) / 3600.0; + + credit_weekly.push(credit); + + let debit = debit.get(week).copied().unwrap_or_default(); + debit_weekly.push(debit); + + let balance = credit - debit; + balance_weekly.push(balance); + + total_credit += credit; + total_debit += debit; + total_balance += balance; + + rolling_weekly.push(total_balance); + } + + credit_weekly.smooth_mut(); + balance_weekly.smooth_mut(); + rolling_weekly.smooth_mut(); + + let mut table = Table::new(); + let format = FormatBuilder::new().column_separator(' ').build(); + table.set_format(format); + table.set_titles( + row![bu->"Week", bu->"Debit", bu->"Credit", bu->"Balance", bu->"Rolling"], + ); + + for (index, week) in weeks.iter().enumerate() { + let mut row = row![ + format!("{:?}", week), + r->debit_weekly[index], + r->credit_weekly[index] + ]; + + row.add_cell(color_by_balance(balance_weekly[index])); + row.add_cell(color_by_balance(rolling_weekly[index])); + + table.add_row(row); + } + + if table.print_tty(color.with_atty()).is_ok() { + println!(); + println!("total debit: {} hours", total_debit.smooth()); + println!("total credit: {} hours", total_credit.smooth()); + println!(); + println!("balance: {}", total_balance.smooth()); + println!(); + } + + Ok(()) +} + +fn color_by_balance(a: f64) -> Cell { + let cell = cell!(a); + + let style = if a.is_sign_positive() { + "Fgr" + } else if a.is_sign_negative() { + "Frr" + } else { + "r" + }; + + cell.style_spec(style) +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000000000000000000000000000000000000..fdd2e3736275fda097ceffca76b51596de2d0f73 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,131 @@ +use std::convert::TryFrom; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use atty::Stream; +use clap::{crate_description, crate_name, crate_version}; +use clap::{App, AppSettings, Arg, ArgGroup, ArgMatches}; + +pub enum Mode { + Balance, + ListCredit, + ListDebit, +} + +#[derive(Clone, Copy, Debug)] +pub enum Color { + Yes, + No, + Always, +} + +impl Color { + pub fn with_atty(self) -> bool { + match self { + Color::Yes => atty::is(Stream::Stdout), + Color::No => false, + Color::Always => true, + } + } +} + +pub struct Arguments { + pub mode: Mode, + pub color: Color, + pub gleeo: Option<PathBuf>, + pub sqlite: Option<PathBuf>, +} + +impl TryFrom<ArgMatches<'_>> for Arguments { + type Error = anyhow::Error; + + fn try_from(args: ArgMatches) -> Result<Self, Self::Error> { + let mode = if args.is_present("list-credit") { + Mode::ListCredit + } else if args.is_present("list-debit") { + Mode::ListDebit + } else { + Mode::Balance + }; + + let color = match args.value_of("color").unwrap() { + "yes" => Color::Yes, + "always" => Color::Always, + "no" => Color::No, + _ => unreachable!("clap::Arg::possible_values"), + }; + + let gleeo = args.value_of("gleeo").map(|p| Path::new(p).into()); + let sqlite = args.value_of("sqlite").map(|p| Path::new(p).into()); + + Ok(Self { + mode, + color, + gleeo, + sqlite, + }) + } +} + +pub fn args() -> Result<Arguments> { + let colorize = atty::is(Stream::Stdout); + + let cli = build(colorize); + let args = cli.get_matches(); + let arguments = Arguments::try_from(args)?; + + Ok(arguments) +} + +pub fn build(colorize: bool) -> App<'static, 'static> { + let colorize = if colorize { + AppSettings::ColoredHelp + } else { + AppSettings::ColorNever + }; + + let list_credit = Arg::with_name("list-credit") + .long("list-credit") + .help("list credit"); + + let list_debit = Arg::with_name("list-debit") + .long("list-debit") + .help("list debit"); + + let mode = ArgGroup::with_name("mode") + .arg("list-credit") + .arg("list-debit"); + + let color = Arg::with_name("color") + .long("color") + .help("control ANSI color escapes") + .takes_value(true) + .possible_values(&["yes", "no", "always"]) + .default_value("yes"); + + let sqlite = Arg::with_name("sqlite") + .long("debit") + .takes_value(true) + .help("sqlite debit file") + .required_unless("list-credit"); + + let gleeo = Arg::with_name("gleeo") + .long("gleeo") + .takes_value(true) + .help("gleeo credit file") + .required_unless("list-debit"); + + App::new(crate_name!()) + .about(crate_description!()) + .version(crate_version!()) + .global_setting(colorize) + .help_short("?") + .help_message("show this help output") + .version_message("show version") + .arg(list_credit) + .arg(list_debit) + .group(mode) + .arg(color) + .arg(sqlite) + .arg(gleeo) +} diff --git a/src/credit/gleeo.rs b/src/credit/gleeo.rs new file mode 100644 index 0000000000000000000000000000000000000000..3d384e2851dd5db9fa986882c2e19f8b9a78a57e --- /dev/null +++ b/src/credit/gleeo.rs @@ -0,0 +1,109 @@ +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, Local}; + +use super::Entry; + +pub fn from_path<P>(path: P) -> Result<Vec<Entry>> +where + P: AsRef<Path>, +{ + let path = path.as_ref(); + + let mut reader = csv::Reader::from_path(path) + .with_context(|| format!("parsing {}", path.display()))?; + + let headers = reader + .headers() + .with_context(|| format!("parsing headers in {}", path.display()))?; + + let duration_index = headers + .iter() + .position(|h| h == "Decimal Duration") + .ok_or_else(|| { + anyhow!("no 'Decimal Duration' column in {}", path.display()) + })?; + + let start_index = headers + .iter() + .position(|h| h == "Start") + .ok_or_else(|| anyhow!("no 'Start' column in {}", path.display()))?; + + let tz_index = + headers + .iter() + .position(|h| h == "TimeZone") + .ok_or_else(|| { + anyhow!("no 'TimeZone' column in {}", path.display()) + })?; + + let mut credit = vec![]; + + for record in reader.records().enumerate() { + let (record_index, record) = record; + + // bump record index so it starts at 1 (readability) + let record_index = record_index + 1; + + let record = record + .with_context(|| format!("parsing record {}", record_index))?; + + let hours = record.get(duration_index).ok_or_else(|| { + anyhow!("no 'Decimal Duration' field in record {}", record_index) + })?; + + let hours = hours.parse::<f64>().with_context(|| { + format!( + "can't parse {:?} as f64 in record {}", + hours, record_index + ) + })?; + + // ALLOW time-tracked entries won't be too large + #[allow(clippy::cast_possible_truncation)] + let seconds = (hours * 3600.0) as i64; + + let start = record.get(start_index).ok_or_else(|| { + anyhow!("no 'Start' field in record {}", record_index) + })?; + + let tz = record.get(tz_index).ok_or_else(|| { + anyhow!("no 'TimeZone' field in record {}", record_index) + })?; + + let tz = match tz.strip_prefix("UTC") { + Some(tz) => tz, + None => tz, + }; + + let tz = if tz.len() == 5 { + tz.replace("+", "+0") + } else { + tz.into() + }; + + let start = format!("{} {}", start, tz); + + let dt = DateTime::parse_from_str(&start, "%Y-%m-%d %H:%M %:z") + .with_context(|| format!("parsing start date {}", start))?; + + let dt: DateTime<Local> = DateTime::from(dt); + let date = dt.date(); + + credit.push(Entry { date, seconds }); + } + + Ok(credit) +} + +#[cfg(test)] +mod tests { + #[test] + fn decimal_duration() { + let s = "0.3333333333333333"; + let h = s.parse::<f64>().unwrap(); + let seconds = (h * 3600.0) as i64; + assert_eq!(seconds, 1200); + } +} diff --git a/src/credit/mod.rs b/src/credit/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..d8858d2b486bd9d57b53e034748f09841bb31711 --- /dev/null +++ b/src/credit/mod.rs @@ -0,0 +1,79 @@ +pub mod gleeo; +pub mod timew; + +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::Result; +use chrono::{Date, Datelike, IsoWeek, Local}; +use prettytable::format::FormatBuilder; +use prettytable::{cell, row, Table}; + +use crate::cli::Color; + +pub struct Credit { + entries: Vec<Entry>, +} + +impl Credit { + pub fn weekly(&self) -> BTreeMap<IsoWeek, i64> { + let mut data = BTreeMap::default(); + + for entry in &self.entries { + let week = entry.date.iso_week(); + *data.entry(week).or_insert(0) += entry.seconds; + } + + data + } +} + +pub struct Entry { + date: Date<Local>, + seconds: i64, +} + +impl Entry { + pub const fn date(&self) -> &Date<Local> { + &self.date + } + + pub const fn seconds(&self) -> i64 { + self.seconds + } +} + +pub fn all<P>(gleeo: P) -> Result<Credit> +where + P: AsRef<Path>, +{ + let mut entries = vec![]; + + let gleeo = gleeo::from_path(gleeo.as_ref())?; + let timew = timew::from_timew_export()?; + + entries.extend(gleeo); + entries.extend(timew); + + Ok(Credit { entries }) +} + +pub fn print<P>(gleeo: P, color: Color) -> Result<()> +where + P: AsRef<Path>, +{ + let credit = all(gleeo)?; + + let mut table = Table::new(); + let format = FormatBuilder::new().column_separator(' ').build(); + table.set_format(format); + table.set_titles(row![bu->"Date", bu->"Seconds"]); + + for entry in credit.entries { + table.add_row(row![entry.date(), r->entry.seconds()]); + } + + let _ignored = table.print_tty(color.with_atty()); + + Ok(()) +} diff --git a/src/credit/timew.rs b/src/credit/timew.rs new file mode 100644 index 0000000000000000000000000000000000000000..1dfbcb26b4beac05273805079fec4559c2990d39 --- /dev/null +++ b/src/credit/timew.rs @@ -0,0 +1,89 @@ +use std::io::Read; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Local, NaiveDateTime, Utc}; +use serde::{Deserialize, Deserializer}; + +use super::Entry; + +pub fn from_timew_export() -> Result<Vec<Entry>> { + let mut child = Command::new("timew") + .arg("export") + .stdout(Stdio::piped()) + .spawn() + .with_context(|| "running 'timew export'")?; + + let stdout = child + .stdout + .take() + .with_context(|| "take 'timew export' output")?; + + let credit = from_reader(stdout)?; + + child.wait().with_context(|| "waiting on 'timew export'")?; + + Ok(credit) +} + +pub fn from_reader<R>(r: R) -> Result<Vec<Entry>> +where + R: Read, +{ + let timew: Vec<Data> = serde_json::from_reader(r)?; + let credit = timew + .into_iter() + .filter_map(|timew| { + timew.end.map(|end| { + let duration = end - timew.start; + + Entry { + date: timew.start.date(), + seconds: duration.num_seconds(), + } + }) + }) + .collect(); + + Ok(credit) +} + +// ---------------------------------------------------------------------------- +// serde helper +// ---------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct Data { + id: usize, + #[serde(deserialize_with = "timew_to_local")] + start: DateTime<Local>, + #[serde(default)] + #[serde(deserialize_with = "timew_end_to_local")] + end: Option<DateTime<Local>>, + #[serde(default)] + tags: Vec<String>, + annotation: Option<String>, +} + +fn timew_to_local<'de, D>(deserializer: D) -> Result<DateTime<Local>, D::Error> +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + let dt = NaiveDateTime::parse_from_str(&s, "%Y%m%dT%H%M%SZ") + .map_err(serde::de::Error::custom)?; + + let dt = DateTime::<Utc>::from_utc(dt, Utc); + let dt = DateTime::from(dt); + + Ok(dt) +} + +fn timew_end_to_local<'de, D>( + deserializer: D, +) -> Result<Option<DateTime<Local>>, D::Error> +where + D: Deserializer<'de>, +{ + timew_to_local(deserializer).map(Some) +} diff --git a/src/debit/mod.rs b/src/debit/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..ff925138ead52631e4459b3aa07e09c71af34176 --- /dev/null +++ b/src/debit/mod.rs @@ -0,0 +1,73 @@ +pub mod sqlite; + +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::Result; +use chrono::naive::NaiveDate; +use chrono::{Datelike, IsoWeek}; +use prettytable::format::FormatBuilder; +use prettytable::{cell, row, Table}; + +use crate::cli::Color; + +pub struct Debit { + entries: Vec<Entry>, +} + +impl Debit { + pub fn weekly(&self) -> BTreeMap<IsoWeek, f64> { + let mut data = BTreeMap::default(); + + for entry in &self.entries { + let week = entry.date.iso_week(); + *data.entry(week).or_insert(0.0) += entry.hours; + } + + data + } +} + +#[derive(Debug)] +pub struct Entry { + date: NaiveDate, + hours: f64, +} + +impl Entry { + pub const fn date(&self) -> &NaiveDate { + &self.date + } + + pub const fn hours(&self) -> f64 { + self.hours + } +} + +pub fn all<P>(sqlite: P) -> Result<Debit> +where + P: AsRef<Path>, +{ + let entries = sqlite::from_path(sqlite)?; + Ok(Debit { entries }) +} + +pub fn print<P>(path: P, color: Color) -> Result<()> +where + P: AsRef<Path>, +{ + let debit = all(path)?; + + let mut table = Table::new(); + let format = FormatBuilder::new().column_separator(' ').build(); + table.set_format(format); + table.set_titles(row![bu->"Date", bu->"Hours"]); + + for entry in debit.entries { + table.add_row(row![entry.date(), r->entry.hours()]); + } + + let _ignored = table.print_tty(color.with_atty()); + + Ok(()) +} diff --git a/src/debit/sqlite.rs b/src/debit/sqlite.rs new file mode 100644 index 0000000000000000000000000000000000000000..980f65249f98552bb4f11b05a9e63715f5504746 --- /dev/null +++ b/src/debit/sqlite.rs @@ -0,0 +1,24 @@ +use std::path::Path; + +use rusqlite::{Connection, Result}; + +use super::Entry; + +pub fn from_path<P>(path: P) -> Result<Vec<Entry>> +where + P: AsRef<Path>, +{ + let path = path.as_ref(); + + let conn = Connection::open(path)?; + + let mut stmt = conn.prepare("SELECT day, hours FROM debit")?; + let iter = stmt.query_map([], |row| { + let date = row.get(0)?; + let hours = row.get(1)?; + + Ok(Entry { date, hours }) + })?; + + Ok(iter.map(Result::unwrap).collect()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..4c09594eef80f4b3b6b6d3edef7fd7e7ccf0c6ad --- /dev/null +++ b/src/main.rs @@ -0,0 +1,23 @@ +#![deny(clippy::all)] +#![warn(clippy::pedantic, clippy::nursery)] + +mod balance; +mod cli; +mod credit; +mod debit; + +use anyhow::Result; + +use self::cli::Mode; + +fn main() -> Result<()> { + let args = cli::args()?; + + match args.mode { + Mode::ListCredit => credit::print(args.gleeo.unwrap(), args.color), + Mode::ListDebit => debit::print(args.sqlite.unwrap(), args.color), + Mode::Balance => { + balance::run(args.gleeo.unwrap(), args.sqlite.unwrap(), args.color) + } + } +} diff --git a/ttanalysis.sc b/ttanalysis.sc deleted file mode 100755 index 9fa01793291406ad2d22d11ec2242024262d0df7..0000000000000000000000000000000000000000 --- a/ttanalysis.sc +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env amm - -import $ivy.`com.github.wookietreiber::scala-chart:0.5.1`, scalax.chart.api._ -import $ivy.`com.github.tototoshi::scala-csv:1.3.5`, com.github.tototoshi.csv._ -import $ivy.`org.typelevel::cats-core:1.1.0` - -import cats._ -import cats.instances.all._ -import cats.syntax.all._ - -import java.time._ - -import sys.process._ - -import ammonite.ops._ - -val weekFields = temporal.WeekFields.of(java.util.Locale.getDefault) - -// ------------------------------------------------------------------------------------------------- -// debit -// ------------------------------------------------------------------------------------------------- - -class Debit(path: Path) { - - val entries: List[(LocalDate,Double)] = for { - entry <- Seq("sqlite3", path.toNIO.toString, "select * from debit").lineStream.toList - - parts = entry.split("\\|") - - date = LocalDate.parse(parts(0)) - hours = parts(1).toDouble - } yield date -> hours - - val total = entries.foldLeft(0.0)(_ + _._2.toDouble) - - val weekly: Map[String,List[Double]] = entries.groupBy({ - case (date,_) => - val year = date.getYear - val week = date.get(weekFields.weekOfWeekBasedYear) - - f"$year%d-$week%02d" - }).mapValues(_.map(_._2)) - - def summary(): Unit = for { - (week,numbers) <- weekly.toSeq.sortBy(_._1) - hours = numbers.sum - } println(s"$week $hours") - -} - -// ------------------------------------------------------------------------------------------------- -// credit -// ------------------------------------------------------------------------------------------------- - -class Credit(path: Path) { - - case class TTEntry(start: LocalDateTime, end: LocalDateTime, duration: Double) { - override def toString: String = { - val d = (duration * 1000).round / 1000.0 - s"""from $start to $end (duration=$d hours)""" - } - } - - val entries = { - val reader = CSVReader.open(path.toIO) - - val ttraw: List[List[String]] = reader.all().drop(1) - - val formatter = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") - - val ttx: List[TTEntry] = for { - entry <- ttraw - - start = LocalDateTime.parse(entry(4), formatter) - end = LocalDateTime.parse(entry(5), formatter) - duration = entry(8).toDouble - } yield TTEntry(start, end, duration) - - reader.close() - - ttx - } - - val total = entries.foldLeft(0.0)(_ + _.duration) - - val weekly: Map[String,List[Double]] = entries.groupBy({ x => - val year = x.start.getYear - val week = x.start.get(weekFields.weekOfWeekBasedYear) - - f"$year%d-$week%02d" - }).mapValues(_.map(_.duration)) - - def summary: Unit = for { - (week,numbers) <- weekly.toSeq.sortBy(_._1) - hours = numbers.sum - } println(s"$week $hours") - -} - -// ------------------------------------------------------------------------------------------------- -// balance -// ------------------------------------------------------------------------------------------------- - -class Balance(debit: Debit, credit: Credit) { - - case class Rolling(week: String, debit: Double, credit: Double, balance: Double, change: Double) - - def rolling: Seq[Rolling] = { - val xx: Map[String,List[Double]] = - debit.weekly.mapValues(_.sum).mapValues(_ :: Nil) |+| - credit.weekly.mapValues(_.sum).mapValues(_ :: Nil) - - var b = 0.0 - - for { - (week,List(debit,credit)) <- xx.toSeq.sortBy(_._1) - change = credit - debit - } yield { - b += change - Rolling(week, debit, credit, b, change) - } - } - -} - -// ------------------------------------------------------------------------------------------------- -// balance -// ------------------------------------------------------------------------------------------------- - -@main -def main(debitIn: Path, creditIn: Path) = { - - val debit = new Debit(debitIn) - - val credit = new Credit(creditIn) - - val balance = new Balance(debit, credit) - - for (r <- balance.rolling) { - val week = r.week - val debit = r.debit - val credit = r.credit - val balance = r.balance - val change = r.change - - val cc = if (change < 0) - f"${Console.RED}${change.round}%5d${Console.RESET}" - else - f"${Console.GREEN}${change.round}%5d${Console.RESET}" - - val cb = if (balance < 0) - f"${Console.RED}${balance.round}%5d${Console.RESET}" - else - f"${Console.GREEN}${balance.round}%5d${Console.RESET}" - - println(f"$week ${debit.round}%5d ${credit.round}%5d $cc $cb") - } - - println(s"""| - |total debit: ${debit.total} hours - |total credit: ${credit.total} hours - | - |balance: ${credit.total - debit.total} hours - |""".stripMargin) - -}