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