├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── src ├── chronny.rs ├── lib.rs ├── main.rs └── tables.rs └── tests └── chronny.rs /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | [root] 2 | name = "timmy" 3 | version = "0.3.0" 4 | dependencies = [ 5 | "ansi_term 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", 6 | "chrono 0.2.25 (registry+https://github.com/rust-lang/crates.io-index)", 7 | "clap 2.11.0 (registry+https://github.com/rust-lang/crates.io-index)", 8 | "env_logger 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", 9 | "lazy_static 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", 10 | "log 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)", 11 | "regex 0.1.73 (registry+https://github.com/rust-lang/crates.io-index)", 12 | "rusqlite 0.7.3 (registry+https://github.com/rust-lang/crates.io-index)", 13 | ] 14 | 15 | [[package]] 16 | name = "aho-corasick" 17 | version = "0.5.2" 18 | source = "registry+https://github.com/rust-lang/crates.io-index" 19 | dependencies = [ 20 | "memchr 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)", 21 | ] 22 | 23 | [[package]] 24 | name = "ansi_term" 25 | version = "0.8.0" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | 28 | [[package]] 29 | name = "bitflags" 30 | version = "0.7.0" 31 | source = "registry+https://github.com/rust-lang/crates.io-index" 32 | 33 | [[package]] 34 | name = "chrono" 35 | version = "0.2.25" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | dependencies = [ 38 | "num 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)", 39 | "time 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)", 40 | ] 41 | 42 | [[package]] 43 | name = "clap" 44 | version = "2.11.0" 45 | source = "registry+https://github.com/rust-lang/crates.io-index" 46 | dependencies = [ 47 | "ansi_term 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", 48 | "bitflags 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", 49 | "kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", 50 | "libc 0.2.15 (registry+https://github.com/rust-lang/crates.io-index)", 51 | "strsim 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", 52 | "term_size 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", 53 | "unicode-segmentation 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", 54 | "unicode-width 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", 55 | "vec_map 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", 56 | "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", 57 | ] 58 | 59 | [[package]] 60 | name = "env_logger" 61 | version = "0.3.4" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | dependencies = [ 64 | "log 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)", 65 | "regex 0.1.73 (registry+https://github.com/rust-lang/crates.io-index)", 66 | ] 67 | 68 | [[package]] 69 | name = "kernel32-sys" 70 | version = "0.2.2" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | dependencies = [ 73 | "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", 74 | "winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", 75 | ] 76 | 77 | [[package]] 78 | name = "lazy_static" 79 | version = "0.2.1" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | 82 | [[package]] 83 | name = "libc" 84 | version = "0.2.15" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | 87 | [[package]] 88 | name = "libsqlite3-sys" 89 | version = "0.5.0" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | dependencies = [ 92 | "libc 0.2.15 (registry+https://github.com/rust-lang/crates.io-index)", 93 | "pkg-config 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", 94 | ] 95 | 96 | [[package]] 97 | name = "linked-hash-map" 98 | version = "0.0.9" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | 101 | [[package]] 102 | name = "log" 103 | version = "0.3.6" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | 106 | [[package]] 107 | name = "lru-cache" 108 | version = "0.0.7" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | dependencies = [ 111 | "linked-hash-map 0.0.9 (registry+https://github.com/rust-lang/crates.io-index)", 112 | ] 113 | 114 | [[package]] 115 | name = "memchr" 116 | version = "0.1.11" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | dependencies = [ 119 | "libc 0.2.15 (registry+https://github.com/rust-lang/crates.io-index)", 120 | ] 121 | 122 | [[package]] 123 | name = "num" 124 | version = "0.1.35" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | dependencies = [ 127 | "num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)", 128 | "num-iter 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)", 129 | "num-traits 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)", 130 | ] 131 | 132 | [[package]] 133 | name = "num-integer" 134 | version = "0.1.32" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | dependencies = [ 137 | "num-traits 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)", 138 | ] 139 | 140 | [[package]] 141 | name = "num-iter" 142 | version = "0.1.32" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | dependencies = [ 145 | "num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)", 146 | "num-traits 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)", 147 | ] 148 | 149 | [[package]] 150 | name = "num-traits" 151 | version = "0.1.35" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | 154 | [[package]] 155 | name = "pkg-config" 156 | version = "0.3.8" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | 159 | [[package]] 160 | name = "regex" 161 | version = "0.1.73" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | dependencies = [ 164 | "aho-corasick 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)", 165 | "memchr 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)", 166 | "regex-syntax 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", 167 | "thread_local 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)", 168 | "utf8-ranges 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", 169 | ] 170 | 171 | [[package]] 172 | name = "regex-syntax" 173 | version = "0.3.4" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | 176 | [[package]] 177 | name = "rusqlite" 178 | version = "0.7.3" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | dependencies = [ 181 | "bitflags 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", 182 | "chrono 0.2.25 (registry+https://github.com/rust-lang/crates.io-index)", 183 | "libc 0.2.15 (registry+https://github.com/rust-lang/crates.io-index)", 184 | "libsqlite3-sys 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", 185 | "lru-cache 0.0.7 (registry+https://github.com/rust-lang/crates.io-index)", 186 | "time 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)", 187 | ] 188 | 189 | [[package]] 190 | name = "strsim" 191 | version = "0.5.1" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | 194 | [[package]] 195 | name = "term_size" 196 | version = "0.1.0" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | dependencies = [ 199 | "libc 0.2.15 (registry+https://github.com/rust-lang/crates.io-index)", 200 | ] 201 | 202 | [[package]] 203 | name = "thread-id" 204 | version = "2.0.0" 205 | source = "registry+https://github.com/rust-lang/crates.io-index" 206 | dependencies = [ 207 | "kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", 208 | "libc 0.2.15 (registry+https://github.com/rust-lang/crates.io-index)", 209 | ] 210 | 211 | [[package]] 212 | name = "thread_local" 213 | version = "0.2.6" 214 | source = "registry+https://github.com/rust-lang/crates.io-index" 215 | dependencies = [ 216 | "thread-id 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", 217 | ] 218 | 219 | [[package]] 220 | name = "time" 221 | version = "0.1.35" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | dependencies = [ 224 | "kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", 225 | "libc 0.2.15 (registry+https://github.com/rust-lang/crates.io-index)", 226 | "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", 227 | ] 228 | 229 | [[package]] 230 | name = "unicode-segmentation" 231 | version = "0.1.2" 232 | source = "registry+https://github.com/rust-lang/crates.io-index" 233 | 234 | [[package]] 235 | name = "unicode-width" 236 | version = "0.1.3" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | 239 | [[package]] 240 | name = "utf8-ranges" 241 | version = "0.1.3" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | 244 | [[package]] 245 | name = "vec_map" 246 | version = "0.6.0" 247 | source = "registry+https://github.com/rust-lang/crates.io-index" 248 | 249 | [[package]] 250 | name = "winapi" 251 | version = "0.2.8" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | 254 | [[package]] 255 | name = "winapi-build" 256 | version = "0.1.1" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | 259 | [metadata] 260 | "checksum aho-corasick 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)" = "2b3fb52b09c1710b961acb35390d514be82e4ac96a9969a8e38565a29b878dc9" 261 | "checksum ansi_term 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c877397e09fec7a240af5fa74ad0124054b8066149d6544cd1ace93f8de3be68" 262 | "checksum bitflags 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "aad18937a628ec6abcd26d1489012cc0e18c21798210f491af69ded9b881106d" 263 | "checksum chrono 0.2.25 (registry+https://github.com/rust-lang/crates.io-index)" = "9213f7cd7c27e95c2b57c49f0e69b1ea65b27138da84a170133fd21b07659c00" 264 | "checksum clap 2.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "62db8e9e3ab6792670f99338be3dbc416f8728ba5d874c33e27aa9933e534512" 265 | "checksum env_logger 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)" = "82dcb9ceed3868a03b335657b85a159736c961900f7e7747d3b0b97b9ccb5ccb" 266 | "checksum kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" 267 | "checksum lazy_static 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "49247ec2a285bb3dcb23cbd9c35193c025e7251bfce77c1d5da97e6362dffe7f" 268 | "checksum libc 0.2.15 (registry+https://github.com/rust-lang/crates.io-index)" = "23e3757828fa702a20072c37ff47938e9dd331b92fac6e223d26d4b7a55f7ee2" 269 | "checksum libsqlite3-sys 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "663508cb9c1e23363aea1a8b1f7d6340394ebc3bc3a6daebfb9cc99b8feaf2ec" 270 | "checksum linked-hash-map 0.0.9 (registry+https://github.com/rust-lang/crates.io-index)" = "83f7ff3baae999fdf921cccf54b61842bb3b26868d50d02dff48052ebec8dd79" 271 | "checksum log 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)" = "ab83497bf8bf4ed2a74259c1c802351fcd67a65baa86394b6ba73c36f4838054" 272 | "checksum lru-cache 0.0.7 (registry+https://github.com/rust-lang/crates.io-index)" = "42d50dcb5d9f145df83b1043207e1ac0c37c9c779c4e128ca4655abc3f3cbf8c" 273 | "checksum memchr 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)" = "d8b629fb514376c675b98c1421e80b151d3817ac42d7c667717d282761418d20" 274 | "checksum num 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)" = "5a9699207fab8b02bd0e56f8f06fee3f26d640303130de548898b4c9704f6d01" 275 | "checksum num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)" = "fb24d9bfb3f222010df27995441ded1e954f8f69cd35021f6bef02ca9552fb92" 276 | "checksum num-iter 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)" = "287a1c9969a847055e1122ec0ea7a5c5d6f72aad97934e131c83d5c08ab4e45c" 277 | "checksum num-traits 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)" = "8359ea48994f253fa958b5b90b013728b06f54872e5a58bce39540fcdd0f2527" 278 | "checksum pkg-config 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)" = "8cee804ecc7eaf201a4a207241472cc870e825206f6c031e3ee2a72fa425f2fa" 279 | "checksum regex 0.1.73 (registry+https://github.com/rust-lang/crates.io-index)" = "56b7ee9f764ecf412c6e2fff779bca4b22980517ae335a21aeaf4e32625a5df2" 280 | "checksum regex-syntax 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)" = "31040aad7470ad9d8c46302dcffba337bb4289ca5da2e3cd6e37b64109a85199" 281 | "checksum rusqlite 0.7.3 (registry+https://github.com/rust-lang/crates.io-index)" = "e9b3854687228334d8a579cd2f666ddd7fb46a5f68ac0460da2898394c4679d2" 282 | "checksum strsim 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "50c069df92e4b01425a8bf3576d5d417943a6a7272fbabaf5bd80b1aaa76442e" 283 | "checksum term_size 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a6a7c9a4de31e5622ec38533988a9e965aab09b26ee8bd7b8b0f56d488c3784d" 284 | "checksum thread-id 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a9539db560102d1cef46b8b78ce737ff0bb64e7e18d35b2a5688f7d097d0ff03" 285 | "checksum thread_local 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = "55dd963dbaeadc08aa7266bf7f91c3154a7805e32bb94b820b769d2ef3b4744d" 286 | "checksum time 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)" = "3c7ec6d62a20df54e07ab3b78b9a3932972f4b7981de295563686849eb3989af" 287 | "checksum unicode-segmentation 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b905d0fc2a1f0befd86b0e72e31d1787944efef9d38b9358a9e92a69757f7e3b" 288 | "checksum unicode-width 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "2d6722facc10989f63ee0e20a83cd4e1714a9ae11529403ac7e0afd069abc39e" 289 | "checksum utf8-ranges 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "a1ca13c08c41c9c3e04224ed9ff80461d97e121589ff27c753a16cb10830ae0f" 290 | "checksum vec_map 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "cac5efe5cb0fa14ec2f84f83c701c562ee63f6dcc680861b21d65c682adfb05f" 291 | "checksum winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" 292 | "checksum winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" 293 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "timmy" 3 | version = "0.3.0" 4 | authors = ["Matthew Hall "] 5 | description = "A time tracker mainly for programming tasks" 6 | repository = "https://github.com/mattyhall/timmy" 7 | license = "BSD-3-Clause" 8 | keywords = ["application"] 9 | 10 | [dependencies] 11 | rusqlite = { version = "0.7.3", features = ["chrono"] } 12 | clap = "2.10.0" 13 | chrono = "0.2" 14 | log = "0.3.6" 15 | env_logger = "0.3.4" 16 | ansi_term = "0.8" 17 | regex = "0.1" 18 | lazy_static = "0.2.1" 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Timmy 2 | [Timmy](https://crates.io/crates/timmy) is a time tracker. At the moment it is not particularly stable or even well written! Things like parsing dates in english (eg. "yesterday 12:00") may be buggy. If you do want to install it run: 3 | 4 | ``` 5 | matt@box:~/$ cargo install timmy 6 | ``` 7 | 8 | and make sure `~/.cargo/bin` or equivalent is in your PATH. 9 | 10 | ## Example usage 11 | 12 | Creating a new project can be done with the new command. Tags are comma separated. 13 | 14 | ``` 15 | matt@box:~/$ # Creating a new project called timmy with the customer 'me' and tags 'rust,cli' 16 | matt@box:~/$ timmy new timmy -c me -t rust,cli 17 | ``` 18 | 19 | You can get a list of projects like so: 20 | 21 | ``` 22 | matt@box:~/$ timmy projects 23 | ┌───────┬──────────┬──────────┐ 24 | │ Name │ Customer │ Tags │ 25 | ├───────┼──────────┼──────────┤ 26 | │ timmy │ me │ cli,rust │ 27 | └───────┴──────────┴──────────┘ 28 | ``` 29 | 30 | You can start tracking using timmy track. You can optionally add a start point or a start and end point to add some time that you forgot to track. 31 | 32 | ``` 33 | matt@box:~/$ timmy track 34 | matt@box:~/$ # Starting time 35 | matt@box:~/$ timmy track -s "12:00" 36 | matt@box:~/$ # Starting time and end 37 | matt@box:~/$ timmy track -s "12:00" -e "13:00" 38 | ``` 39 | 40 | Timmy will monitor what programs you are using, this can be disabled by passing `-n`. At the end of a session timmy will automatically look for any git commits in the repo in the current directory. If you edit history (eg. reverting a commit) you can run: 41 | 42 | ``` 43 | matt@box:~/$ timmy git 44 | ``` 45 | 46 | and it will repopulate the commits for the project. 47 | 48 | To get all information on a particular project use the project command: 49 | 50 | ``` 51 | matt@box:~/$ timmy project timmy 52 | timmy 53 | Tags: rust,cli 54 | Total time spent: 14hrs 31mins 55 | 56 | Program usage 57 | 98.97% emacs 58 | 1.03% gnome-terminal- 59 | 60 | Activity 61 | Thu 25 August 2016 17:55-18:02 6mins 62 | * Print what commits have been found 63 | * Add total time to activity printout 64 | Thu 25 August 2016 17:44-17:54 9mins 65 | * Add week flag to show activity in last week 66 | Thu 25 August 2016 17:18-17:27 9mins 67 | * Add support for dates like 20/08 to chronny 68 | * Fix 20/08 date not being parsed 69 | Thu 25 August 2016 16:32-17:15 42mins 70 | * Add filtering activity by dates 71 | * Add absolute dates to chronny 72 | * Remove id from projects table 73 | Wed 24 August 2016 14:09-14:30 21mins 74 | * Add start and end options to track 75 | * Add kitchen sink test for chronny 76 | Tue 23 August 2016 20:35-21:24 48mins 77 | * Add relative times 78 | Tue 23 August 2016 15:47-17:12 1hrs 25mins 79 | * Start writing a human datetime parsing lib 80 | * Use i64 for row ids 81 | * Automatically look for commits at the end of track 82 | Tue 23 August 2016 15:28-15:46 18mins 83 | Mon 22 August 2016 21:18-21:27 8mins: Example usage 84 | * Extend readme with example usage 85 | Mon 22 August 2016 20:30-21:04 34mins 86 | * Add short weeks view 87 | * Use debug! instead of error! 88 | * Rename week command to weeks 89 | * Stop projects panicing when project is not found 90 | Mon 22 August 2016 15:38-17:47 2hrs 9mins 91 | * Add total separator to week view 92 | * Use tables lib in timmy 93 | * Remove double bordering from tables 94 | * First attempt at a tables lib 95 | Mon 22 August 2016 14:50-15:33 42mins 96 | * Add total to weeks view 97 | * Move formatting a time difference into a function 98 | * First version of weeks view 99 | Mon 22 August 2016 14:20-14:41 20mins 100 | Sun 21 August 2016 18:22-18:25 2mins: bugfixing 101 | * Make total time display correct 102 | Sun 21 August 2016 17:04-18:20 1hrs 15mins: Upload to github 103 | * Add readme 104 | * Make project view work with no timeperiods or tags 105 | Sun 21 August 2016 15:49-16:03 13mins 106 | * Remove abc 107 | * Refactor project view 108 | * Fix clippy warnings 109 | * Rename SqliteError to Sqlite 110 | Sun 21 August 2016 15:23-15:48 24mins: Clean up 111 | Sun 21 August 2016 14:50-15:03 12mins: Total time on project vew 112 | * Add total time to project view 113 | Sun 21 August 2016 14:35-14:46 11mins: Fix projects list 114 | * Fix projects view 115 | Sun 21 August 2016 13:48-14:24 35mins: Project view 116 | * Add tags to project view 117 | * Move getting tags into query 118 | * Add project view 119 | Sat 20 August 2016 21:28-21:42 13mins: Project view 120 | Sat 20 August 2016 20:06-20:47 41mins: Views 121 | * Refactor printing a row into a function 122 | * Implement projects list 123 | Sat 20 August 2016 18:39-19:21 42mins: Views 124 | Sat 20 August 2016 14:50-14:57 6mins: Test sqlites support for times 125 | Sat 20 August 2016 13:45-13:59 13mins 126 | * Use question mark instead of try! 127 | * Add description to timeperiods 128 | * Rename timeperiod to timeperiods for consistency 129 | Sat 20 August 2016 13:40-13:41 0mins 130 | Sat 20 August 2016 12:56-13:38 41mins 131 | Fri 19 August 2016 19:18-19:58 39mins 132 | Fri 19 August 2016 18:08-18:23 15mins 133 | * Use own error type 134 | Total: 14hrs 31mins 135 | ``` 136 | 137 | This is quite long so you can get the activity between certain times or dates like so: 138 | 139 | ``` 140 | matt@box:~/$ timmy project timmy -s 22/08/16 -u 24/08/16 141 | timmy 142 | Tags: rust,cli 143 | Total time spent: 14hrs 31mins 144 | 145 | Program usage 146 | 98.97% emacs 147 | 1.03% gnome-terminal- 148 | 149 | Activity 150 | Wed 24 August 2016 14:09-14:30 21mins 151 | * Add start and end options to track 152 | * Add kitchen sink test for chronny 153 | Tue 23 August 2016 20:35-21:24 48mins 154 | * Add relative times 155 | Tue 23 August 2016 15:47-17:12 1hrs 25mins 156 | * Start writing a human datetime parsing lib 157 | * Use i64 for row ids 158 | * Automatically look for commits at the end of track 159 | Tue 23 August 2016 15:28-15:46 18mins 160 | Mon 22 August 2016 21:18-21:27 8mins: Example usage 161 | * Extend readme with example usage 162 | Mon 22 August 2016 20:30-21:04 34mins 163 | * Add short weeks view 164 | * Use debug! instead of error! 165 | * Rename week command to weeks 166 | * Stop projects panicing when project is not found 167 | Total: 3hrs 37mins 168 | ``` 169 | 170 | You can even use English date descriptions like so: 171 | 172 | ``` 173 | matt@box:~/$ timmy project timmy -s "yesterday 12:00" 174 | timmy 175 | Tags: rust,cli 176 | Total time spent: 14hrs 31mins 177 | 178 | Program usage 179 | 98.97% emacs 180 | 1.03% gnome-terminal- 181 | 182 | Activity 183 | Thu 25 August 2016 17:55-18:02 6mins 184 | * Print what commits have been found 185 | * Add total time to activity printout 186 | Thu 25 August 2016 17:44-17:54 9mins 187 | * Add week flag to show activity in last week 188 | Thu 25 August 2016 17:18-17:27 9mins 189 | * Add support for dates like 20/08 to chronny 190 | * Fix 20/08 date not being parsed 191 | Thu 25 August 2016 16:32-17:15 42mins 192 | * Add filtering activity by dates 193 | * Add absolute dates to chronny 194 | * Remove id from projects table 195 | Total: 1hrs 8mins 196 | ``` 197 | 198 | You can get a week by week view of a project as well: 199 | 200 | ``` 201 | matt@box:~/$ timmy weeks timmy 202 | ┌──────────┬───────┬─────────────┐ 203 | │ Week │ Day │ Time │ 204 | ├──────────┼───────┼─────────────┤ 205 | │ 22/08/16 │ Mon │ 3hrs 56mins │ 206 | │ │ Tue │ 2hrs 32mins │ 207 | │ │ Wed │ 21mins │ 208 | │ │ Thu │ 1hrs 8mins │ 209 | │ ├───────┼─────────────┤ 210 | │ │ Total │ 7hrs 58mins │ 211 | ├──────────┼───────┼─────────────┤ 212 | │ 15/08/16 │ Fri │ 55mins │ 213 | │ │ Sat │ 2hrs 40mins │ 214 | │ │ Sun │ 2hrs 57mins │ 215 | │ ├───────┼─────────────┤ 216 | │ │ Total │ 6hrs 33mins │ 217 | └──────────┴───────┴─────────────┘ 218 | ``` 219 | -------------------------------------------------------------------------------- /src/chronny.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use chrono::*; 3 | 4 | pub fn parse_datetime(s: &str, now: DateTime) -> Option> { 5 | lazy_static! { 6 | static ref DATE_WORDS_RE: Regex = Regex::new(r"today|yesterday|now").unwrap(); 7 | static ref DATE_ABSOLUTE_RE: Regex = Regex::new(r"(?P\d{1,2})/(?P\d{1,2})(/(?P\d{4}|\d{2}))?").unwrap(); 8 | static ref DATE_RELATIVE_RE: Regex = Regex::new(r"(?P\d+) (days?|ds?)(?P( ago)?)").unwrap(); 9 | static ref TIME_ABSOLUTE_RE: Regex = Regex::new(r"(?P
\d{2}):(?P\d{2})").unwrap(); 10 | static ref TIME_RELATIVE_RE: Regex = Regex::new(r"(?x) 11 | (?P\d+) \s (?Phrs?|hours?|hs?|minutes?|mins?|ms?) (?P(\s ago)?)").unwrap(); 12 | } 13 | 14 | let now = if let Some(caps) = DATE_WORDS_RE.captures(s) { 15 | match caps.at(0) { 16 | Some("now") | Some("today") | None => now, 17 | Some("yesterday") => now - Duration::days(1), 18 | _ => unreachable!(), 19 | } 20 | } else if let Some(caps) = DATE_ABSOLUTE_RE.captures(s) { 21 | let day = caps.name("day").unwrap().parse().unwrap(); 22 | let month = caps.name("month").unwrap().parse().unwrap(); 23 | let current_year: i32 = Local::now().year() / 1000; 24 | match caps.name("year") { 25 | Some(s) if s.len() == 2 => 26 | now.with_year(1000 * current_year + s.parse::().unwrap()).unwrap(), 27 | Some(s) if s.len() == 4 => now.with_year(s.parse().unwrap()).unwrap(), 28 | None => now, 29 | _ => unreachable!(), 30 | }.with_month(month).unwrap().with_day(day).unwrap() 31 | } else if let Some(caps) = DATE_RELATIVE_RE.captures(s) { 32 | let n = caps.name("n").unwrap().parse().unwrap(); 33 | let duration = Duration::days(n); 34 | match caps.name("ago") { 35 | Some(s) if s.ends_with("ago") => now - duration, 36 | _ => now + duration, 37 | } 38 | } else { 39 | now 40 | }; 41 | 42 | let now = if let Some(caps) = TIME_ABSOLUTE_RE.captures(s) { 43 | let hr = caps.name("hr").unwrap().parse().unwrap(); 44 | let now = now.with_hour(hr).unwrap(); 45 | let min = caps.name("mins").unwrap().parse().unwrap(); 46 | now.with_minute(min).unwrap() 47 | } else if let Some(caps) = TIME_RELATIVE_RE.captures(s) { 48 | let n = caps.name("n").unwrap().parse().unwrap(); 49 | let dur = caps.name("dur").unwrap(); 50 | let duration = if dur.starts_with("h") { 51 | Duration::hours(n) 52 | } else { 53 | Duration::minutes(n) 54 | }; 55 | match caps.name("ago") { 56 | Some(s) if s.ends_with("ago") => now - duration, 57 | _ => now + duration 58 | } 59 | } else { 60 | now 61 | }; 62 | Some(now) 63 | } 64 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] extern crate lazy_static; 2 | extern crate regex; 3 | extern crate chrono; 4 | 5 | pub mod tables; 6 | pub mod chronny; 7 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(question_mark)] 2 | 3 | extern crate timmy; 4 | 5 | #[macro_use] 6 | extern crate log; 7 | extern crate env_logger; 8 | 9 | #[macro_use] 10 | extern crate lazy_static; 11 | 12 | extern crate clap; 13 | extern crate rusqlite; 14 | extern crate chrono; 15 | extern crate ansi_term; 16 | extern crate regex; 17 | 18 | use std::{fs, env, io, time, thread}; 19 | use std::process::Stdio; 20 | use std::collections::HashMap; 21 | use std::sync::mpsc::{Receiver, channel}; 22 | use std::path::Path; 23 | use std::convert::From; 24 | use std::process::Command; 25 | use clap::{Arg, App, SubCommand}; 26 | use rusqlite::{Connection, Statement}; 27 | use regex::Regex; 28 | use chrono::*; 29 | use ansi_term::Style; 30 | use timmy::tables::*; 31 | use timmy::chronny; 32 | 33 | #[derive(Debug)] 34 | enum Error { 35 | ProjectNotFound(String), 36 | ProjectAlreadyExists(String), 37 | Sqlite(rusqlite::Error), 38 | Git, 39 | InvalidDateTime(String), 40 | InactiveProject(String), 41 | } 42 | 43 | impl From for Error { 44 | fn from(e: rusqlite::Error) -> Error { 45 | Error::Sqlite(e) 46 | } 47 | } 48 | 49 | fn open_connection() -> Result { 50 | let home = env::var("HOME").unwrap_or("./".into()); 51 | let path = Path::new(&home).join(".timmy"); 52 | if !path.exists() { 53 | fs::create_dir(&path).unwrap(); 54 | } 55 | let path = path.join("db.sqlite3"); 56 | let conn = Connection::open(path)?; 57 | 58 | conn.execute_batch("CREATE TABLE IF NOT EXISTS projects ( 59 | id INTEGER PRIMARY KEY, 60 | name TEXT NOT NULL UNIQUE, 61 | customer TEXT 62 | ); 63 | CREATE TABLE IF NOT EXISTS tags_projects_join ( 64 | tag_name TEXT NOT NULL, 65 | project_id INTEGER NOT NULL, 66 | UNIQUE(tag_name, project_id) 67 | ); 68 | CREATE TABLE IF NOT EXISTS timeperiods ( 69 | id INTEGER PRIMARY KEY, 70 | project_id INTEGER NOT NULL, 71 | description TEXT, 72 | start DATETIME NOT NULL, 73 | end DATETIME NOT NULL 74 | ); 75 | CREATE TABLE IF NOT EXISTS commits ( 76 | sha TEXT NOT NULL UNIQUE, 77 | summary TEXT NOT NULL, 78 | project_id INTEGER NOT NULL, 79 | timeperiod_id INTEGER NOT NULL); 80 | 81 | CREATE TABLE IF NOT EXISTS program_usage ( 82 | project_id INTEGER NOT NULL, 83 | program TEXT NOT NULL, 84 | time INTEGER NOT NULL);")?; 85 | 86 | let _ = conn.execute("ALTER TABLE projects ADD COLUMN active BOOLEAN NOT NULL DEFAULT 1;", &[]); 87 | Ok(conn) 88 | } 89 | 90 | fn format_time(time: f64) -> String { 91 | if time > 1.0 { 92 | format!("{}hrs {}mins", 93 | time.floor(), 94 | (60.0 * (time - time.floor())).floor()) 95 | } else if time > 0.0 { 96 | format!("{}mins", (time * 60.0).floor()) 97 | } else { 98 | format!("None") 99 | } 100 | } 101 | 102 | fn create_project(conn: &mut Connection, 103 | name: &str, 104 | customer: Option<&str>, 105 | tags: &str) 106 | -> Result<(), Error> { 107 | match find_project(conn, name) { 108 | Ok(_) => return Err(Error::ProjectAlreadyExists(name.into())), 109 | _ => {}, 110 | }; 111 | let tx = conn.transaction()?; 112 | tx.execute("INSERT INTO projects(name, customer) VALUES (?,?)", 113 | &[&name, &customer])?; 114 | let proj_id = tx.last_insert_rowid(); 115 | if tags != "" { 116 | for tag in tags.split(',') { 117 | let _ = tx.execute("INSERT INTO tags_projects_join VALUES (?, ?)", 118 | &[&tag, &proj_id]); 119 | } 120 | } 121 | tx.commit()?; 122 | Ok(()) 123 | } 124 | 125 | fn change_project_status(conn: &mut Connection, name: &str, activity: bool) -> Result<(), Error> { 126 | let (proj_id, _) = find_project(conn, name)?; 127 | conn.execute("UPDATE projects SET active=? WHERE id=?", &[&activity, &proj_id])?; 128 | Ok(()) 129 | } 130 | 131 | fn finish_project(conn: &mut Connection, name: &str) -> Result<(), Error> { 132 | change_project_status(conn, name, false) 133 | } 134 | 135 | fn restart_project(conn: &mut Connection, name: &str) -> Result<(), Error> { 136 | change_project_status(conn, name, true) 137 | } 138 | 139 | fn find_project(conn: &mut Connection, name: &str) -> Result<(i64, bool), Error> { 140 | match conn.query_row("SELECT id, active FROM projects WHERE name=?", 141 | &[&name], 142 | |row| (row.get(0), row.get(1))) { 143 | Ok((id, active)) => Ok((id, active)), 144 | Err(rusqlite::Error::QueryReturnedNoRows) => Err(Error::ProjectNotFound(name.into())), 145 | Err(e) => Err(Error::from(e)), 146 | } 147 | } 148 | 149 | fn get_current_program() -> String { 150 | // Get the X id for the currently displayed window 151 | let output = Command::new("xprop").args(&["-root", "_NET_ACTIVE_WINDOW"]).output().unwrap(); 152 | // parse: _NET_ACTIVE_WINDOW(WINDOW): window id # 0x3e0000a 153 | let output = String::from_utf8_lossy(&output.stdout); 154 | let win_id = output.split(' ').last().unwrap(); 155 | // Get the pid 156 | let output = Command::new("xprop").args(&["-id", &format!("{}", win_id)]).output().unwrap(); 157 | let output = String::from_utf8_lossy(&output.stdout); 158 | lazy_static! { 159 | static ref REGEX: Regex = Regex::new(r"_NET_WM_PID\(CARDINAL\) = (\d+)").unwrap(); 160 | } 161 | let caps = REGEX.captures(&output).unwrap(); 162 | let pid = caps.at(1).unwrap(); 163 | let output = Command::new("ps").args(&["-ocomm=", &format!("-p{}", pid)]).output().unwrap(); 164 | String::from_utf8_lossy(&output.stdout).into_owned() 165 | } 166 | 167 | fn program_tracker_thread(rx: Receiver) 168 | -> Option>> 169 | { 170 | let status = Command::new("xprop").arg("-root").stdout(Stdio::null()).status(); 171 | match status { 172 | Ok(_) => {}, 173 | Err(_) => return None 174 | } 175 | let handle = thread::spawn(move || { 176 | let mut hm = HashMap::new(); 177 | let mut current_program = get_current_program().trim().into(); 178 | let mut program_change_time = Local::now(); 179 | while rx.try_recv().is_err() { 180 | let new_program = get_current_program().trim().into(); 181 | if new_program != current_program { 182 | let diff = Local::now() - program_change_time; 183 | program_change_time = Local::now(); 184 | let counter = hm.entry(current_program).or_insert(0); 185 | *counter += diff.num_seconds(); 186 | current_program = new_program; 187 | } 188 | thread::sleep(time::Duration::from_millis(100)); 189 | } 190 | return hm; 191 | }); 192 | Some(handle) 193 | } 194 | 195 | fn track(conn: &mut Connection, 196 | name: &str, 197 | description: Option<&str>, 198 | start: Option<&str>, 199 | end: Option<&str>, 200 | no_program: bool) -> Result<(), Error> { 201 | let (proj_id, active) = find_project(conn, name)?; 202 | if !active { 203 | return Err(Error::InactiveProject(name.into())); 204 | } 205 | let start = if let Some(start) = start { 206 | chronny::parse_datetime(start, Local::now()).ok_or(Error::InvalidDateTime(start.into()))? 207 | } else { 208 | Local::now() 209 | }; 210 | println!("Starting at {}", start.format("%d/%m/%y %H:%M")); 211 | let (end, times) = if let Some(end) = end { 212 | (chronny::parse_datetime(end, Local::now()).ok_or(Error::InvalidDateTime(end.into()))?, 213 | HashMap::new()) 214 | } else { 215 | let (tx, rx) = channel(); 216 | let handle = program_tracker_thread(rx); 217 | println!("When you are finished with the task press ENTER"); 218 | let mut s = String::new(); 219 | io::stdin().read_line(&mut s).unwrap(); 220 | let _ = tx.send(true); 221 | let mut times = HashMap::new(); 222 | if let Some(handle) = handle { 223 | if !no_program { 224 | times = handle.join().expect("couldn't join program tracker thread"); 225 | } 226 | } 227 | debug!("program times: {:?}", times); 228 | (Local::now(), times) 229 | }; 230 | println!("Ending at {}", end.format("%d/%m/%y %H:%M")); 231 | 232 | let tx = conn.transaction()?; 233 | tx.execute("INSERT INTO timeperiods(project_id, start, end, description) VALUES (?,?,?,?)", 234 | &[&proj_id, &start, &end, &description])?; 235 | let period_id = tx.last_insert_rowid(); 236 | { 237 | let mut stmnt = tx.prepare("INSERT INTO commits (sha, summary, project_id, timeperiod_id) \ 238 | values(?,?,?,?)")?; 239 | match get_commits(&mut stmnt, proj_id, period_id, &start, &end) { 240 | Ok(()) => {}, 241 | Err(Error::Git) => println!("Git either isn't installed or there is no repo in the \ 242 | current working directory. To associate commits with this \ 243 | project run `timmy git ` in a directory with a \ 244 | git repo."), 245 | e => return e, 246 | }; 247 | let mut stmnt = tx.prepare("INSERT INTO program_usage(project_id, program, time) VALUES (?,?,?)")?; 248 | for (program, time) in × { 249 | let old_time: i64 = tx.query_row("SELECT time FROM program_usage WHERE project_id=? AND program=?", 250 | &[&proj_id, program], 251 | |row| row.get(0)) 252 | .unwrap_or(0); 253 | tx.execute("DELETE FROM program_usage WHERE project_id=? AND program=?", &[&proj_id, program])?; 254 | let time = time + old_time; 255 | stmnt.execute(&[&proj_id, program, &time])?; 256 | } 257 | } 258 | tx.commit()?; 259 | Ok(()) 260 | } 261 | 262 | fn get_commits(insert_stmnt: &mut Statement, proj_id: i64, period_id: i64, start: &DateTime, end: &DateTime) -> Result<(), Error> { 263 | let mut cmd = Command::new("git"); 264 | cmd.arg("whatchanged") 265 | .arg(format!("--since={}", start.to_rfc3339())) 266 | .arg(format!("--until={}", end.to_rfc3339())) 267 | .arg("-q"); 268 | debug!("executing {:?}", cmd); 269 | let output = cmd.output() 270 | .map_err(|e| { 271 | debug!("{:?}", e); 272 | Error::Git 273 | })?; 274 | 275 | if !output.status.success() { 276 | debug!("Git error: {}", String::from_utf8_lossy(&output.stderr)); 277 | return Err(Error::Git); 278 | } 279 | let s: String = String::from_utf8_lossy(&output.stdout).into_owned(); 280 | 281 | let mut lines = s.lines(); 282 | 283 | while let Some(line) = lines.next() { 284 | // parses the following: 285 | 286 | // commit f04a366b0da4377b2f1e87dc9ec68bdf68c24cee 287 | // Author: Matthew Hall 288 | // Date: Sun Aug 21 15:00:43 2016 +0100 289 | // 290 | // Add total time to project view 291 | 292 | if line.starts_with("commit") { 293 | let sha = line.split(' ').nth(1).unwrap(); 294 | debug!("{}", sha); 295 | // skip author 296 | lines.next(); 297 | // skip date 298 | lines.next(); 299 | // skip newline 300 | lines.next(); 301 | // parse summary 302 | let summary = lines.next().unwrap().trim(); 303 | println!("Found commit {}: {}", sha, summary); 304 | insert_stmnt.execute(&[&sha, &summary, &proj_id, &period_id])?; 305 | } 306 | } 307 | Ok(()) 308 | } 309 | 310 | fn git(conn: &mut Connection, project: &str) -> Result<(), Error> { 311 | let (proj_id, active) = find_project(conn, project)?; 312 | if !active { 313 | return Err(Error::InactiveProject(project.into())); 314 | } 315 | let tx = conn.transaction()?; 316 | 317 | tx.execute("DELETE FROM commits WHERE project_id=?", &[&proj_id])?; 318 | 319 | // tx.prepare borrows tx so to call commit stmnt must be dropped 320 | { 321 | let mut stmnt = tx.prepare("SELECT id, start, end FROM timeperiods WHERE project_id=?")?; 322 | let mut rows = stmnt.query(&[&proj_id])?; 323 | let mut insert_stmnt = tx.prepare("INSERT INTO commits (sha, summary, project_id, timeperiod_id) \ 324 | values(?,?,?,?)")?; 325 | while let Some(row) = rows.next() { 326 | let row = row?; 327 | let period_id: i64 = row.get(0); 328 | let start: DateTime = row.get(1); 329 | let end: DateTime = row.get(2); 330 | get_commits(&mut insert_stmnt, proj_id, period_id, &start, &end)?; 331 | } 332 | } 333 | 334 | tx.commit()?; 335 | Ok(()) 336 | } 337 | 338 | fn projects(conn: &mut Connection, all: bool) -> Result<(), Error> { 339 | let mut projects_stmnt = if all { 340 | conn.prepare("SELECT name, customer, group_concat(tag_name), active FROM projects 341 | LEFT JOIN tags_projects_join on project_id=projects.id 342 | GROUP BY id;")? 343 | } else { 344 | conn.prepare("SELECT name, customer, group_concat(tag_name), active FROM projects 345 | LEFT JOIN tags_projects_join on project_id=projects.id 346 | WHERE active=1 347 | GROUP BY id;")? 348 | }; 349 | let rows = 350 | projects_stmnt.query_map(&[], |row| (row.get(0), row.get(1), row.get(2), row.get(3)))?; 351 | let mut headers = vec!["Name".into(), "Customer".into(), "Tags".into()]; 352 | if all { headers.push("Active".into()); } 353 | let mut table = Table::with_headers(headers); 354 | for row in rows { 355 | let (name, customer, tags, active): (String, Option, Option, bool) = row?; 356 | let mut row = vec![name, customer.unwrap_or("".into()), tags.unwrap_or("".into())]; 357 | if all { row.push(format!("{}", active)); } 358 | table.add_simple(row); 359 | } 360 | table.add_border_bottom(); 361 | table.print(); 362 | Ok(()) 363 | } 364 | 365 | fn print_activity(conn: &mut Connection, id: i64, week: bool, since: Option<&str>, until: Option<&str>) -> Result<(), Error> { 366 | let mut since = if let Some(since) = since { 367 | debug!("{}", since); 368 | chronny::parse_datetime(since, Local::now()).ok_or(Error::InvalidDateTime(since.into()))? 369 | } else { 370 | Local::now().with_year(1).unwrap() 371 | }; 372 | let until = if let Some(until) = until { 373 | debug!("{}", until); 374 | chronny::parse_datetime(until, Local::now()).ok_or(Error::InvalidDateTime(until.into()))? 375 | } else { 376 | Local::now() 377 | }; 378 | if week { 379 | since = Local::now() - Duration::days(7); 380 | } 381 | debug!("printing activity between {:?} and {:?}", since, until); 382 | let mut periods_stmnt = 383 | conn.prepare("SELECT id, start, end, description, 384 | CAST((julianday(end)-julianday(start))*24 AS REAL) 385 | FROM timeperiods 386 | WHERE project_id=? AND start > ? AND start < ? 387 | ORDER BY start DESC")?; 388 | let rows = periods_stmnt.query_map(&[&id, &since, &until], 389 | |row| (row.get(0), row.get(1), row.get(2), row.get(3), row.get(4)))?; 390 | 391 | let subtitle_style = Style::new().underline(); 392 | println!("{}", subtitle_style.paint("Activity")); 393 | 394 | let mut total = 0.0f64; 395 | for row in rows { 396 | let (timeperiod_id, start, end, description, time): (i64, 397 | DateTime, 398 | DateTime, 399 | Option, 400 | f64) = row?; 401 | total += time; 402 | let time_string = format_time(time); 403 | let description_string = if let Some(desc) = description { 404 | format!(": {}", desc) 405 | } else { 406 | "".into() 407 | }; 408 | let time_fmt = "%H:%M"; 409 | println!("{} {}-{} {}{}", 410 | start.format("%a %d %B %Y"), 411 | start.format(time_fmt), 412 | end.format(time_fmt), 413 | time_string, 414 | description_string); 415 | 416 | let mut commits_stmnt = conn.prepare("SELECT summary FROM commits WHERE timeperiod_id=?")?; 417 | let commits = commits_stmnt.query_map(&[&timeperiod_id], |row| (row.get(0)))?; 418 | for commit in commits { 419 | let msg: String = commit?; 420 | println!(" * {}", msg); 421 | } 422 | } 423 | println!("Total: {}", format_time(total)); 424 | Ok(()) 425 | } 426 | 427 | fn print_project_summary(conn: &mut Connection, 428 | id: i64, 429 | name: &str, 430 | customer: Option, 431 | tags: Option) 432 | -> Result<(), Error> 433 | { 434 | let title_style = Style::new().underline().bold(); 435 | print!("{}", title_style.paint(name)); 436 | 437 | if let Some(customer) = customer { 438 | print!("{}", 439 | title_style.paint(format!("for {}", customer))); 440 | } 441 | println!(""); 442 | 443 | if let Some(tags) = tags { 444 | println!("Tags: {}", tags); 445 | } 446 | 447 | let total_time: Option = 448 | conn.query_row("SELECT SUM(CAST((julianday(end)-julianday(start))*24 as REAL)) 449 | FROM timeperiods WHERE project_id=?", 450 | &[&id], 451 | |row| row.get(0))?; 452 | let total_time = total_time.unwrap_or(0.0); 453 | let total_time_str = format_time(total_time); 454 | println!("Total time spent: {}", total_time_str); 455 | println!(""); 456 | Ok(()) 457 | } 458 | 459 | fn print_program_usage(conn: &mut Connection, id: i64, total_time: Option) -> Result<(), Error> { 460 | if let Some(total_time) = total_time { 461 | let subtitle_style = Style::new().underline(); 462 | println!("{}", subtitle_style.paint("Program usage")); 463 | let mut stmnt = conn.prepare("SELECT program, time FROM program_usage WHERE project_id=? ORDER BY time DESC")?; 464 | let rows = stmnt.query_map(&[&id], |row| (row.get(0), row.get(1)))?; 465 | for row in rows { 466 | let (program, time): (String, i64) = row?; 467 | if time == 0 { 468 | continue; 469 | } 470 | let pc: f32 = (time as f32) / (total_time as f32) * 100f32; 471 | debug!("pc, time, total_time: {} {} {}", pc, time, total_time); 472 | println!("{:>5.2}% {}", pc, program); 473 | } 474 | println!(""); 475 | } 476 | Ok(()) 477 | } 478 | 479 | fn project(conn: &mut Connection, 480 | name: &str, 481 | week: bool, 482 | since: Option<&str>, 483 | until: Option<&str>, 484 | short: bool) 485 | -> Result<(), Error> 486 | { 487 | let (id, customer, tags, time): (i64, Option, Option, Option) = 488 | conn.query_row("SELECT id, customer, group_concat(tag_name), 489 | (SELECT SUM(time) FROM program_usage WHERE project_id=id) 490 | FROM projects 491 | LEFT JOIN tags_projects_join ON tags_projects_join.project_id=projects.id 492 | WHERE name=?", 493 | &[&name], 494 | |row| { 495 | let id: Option = row.get(0); 496 | if let None = id { 497 | return Err(Error::ProjectNotFound(name.into())); 498 | } 499 | Ok((row.get(0), row.get(1), row.get(2), row.get(3))) 500 | })??; 501 | print_project_summary(conn, id, name, customer, tags)?; 502 | print_program_usage(conn, id, time)?; 503 | if !short { 504 | print_activity(conn, id, week, since, until)?; 505 | } 506 | Ok(()) 507 | } 508 | 509 | fn weeks(conn: &mut Connection, name: &str) -> Result<(), Error> { 510 | let (project_id, _) = find_project(conn, name)?; 511 | let mut day_stmnt = 512 | conn.prepare("SELECT start, 513 | SUM(CAST((julianday(end)-julianday(start))*24 AS REAL)) 514 | FROM timeperiods 515 | WHERE project_id=? 516 | GROUP BY strftime('%j', start) 517 | ORDER BY strftime('%Y%W', start) DESC, start")?; 518 | let rows = day_stmnt.query_map(&[&project_id], |row| (row.get(0), row.get(1)))?; 519 | let mut week = 0; 520 | let mut year = 0; 521 | let mut start_of_week = NaiveDate::from_isoywd(1, 1, Weekday::Mon); 522 | let mut table = Table::with_headers(vec!["Week".into(), "Day".into(), "Time".into()]); 523 | let mut total_time = -1.0; 524 | let total_separator = vec![Cell::new_left_bordered(CellType::Data("".into()), "│"), 525 | Cell::new_left_bordered(CellType::Separator, "├"), 526 | Cell::new_both_bordered(CellType::Separator, "┼", "┤")]; 527 | for row in rows { 528 | let (start, time): (DateTime, f64) = row?; 529 | let (y,w,_) = start.isoweekdate(); 530 | let time_str = format_time(time); 531 | let week_str = if w != week || y != year { 532 | week = w; 533 | year = y; 534 | start_of_week = NaiveDate::from_isoywd(y, w, Weekday::Mon); 535 | if total_time >= 0.0 { 536 | table.add_row(total_separator.clone()); 537 | table.add_simple(vec!["".into(), "Total".into(), format_time(total_time)]); 538 | table.add_full_separator(); 539 | } 540 | total_time = 0.0; 541 | format!("{}", start_of_week.format("%d/%m/%y")) 542 | } else { 543 | "".into() 544 | }; 545 | total_time += time; 546 | table.add_simple(vec![week_str, format!("{}", start.format("%a")), time_str]); 547 | } 548 | table.add_row(total_separator.clone()); 549 | table.add_simple(vec!["".into(), "Total".into(), format_time(total_time)]); 550 | table.add_border_bottom(); 551 | table.print(); 552 | Ok(()) 553 | } 554 | 555 | fn short_weeks(conn: &mut Connection, name: &str) -> Result<(), Error> { 556 | let (project_id, _) = find_project(conn, name)?; 557 | let mut weeks_stmnt = 558 | conn.prepare("SELECT start, 559 | SUM(CAST((julianday(end)-julianday(start))*24 AS REAL)) 560 | FROM timeperiods 561 | WHERE project_id=? 562 | GROUP BY strftime('%W', start) 563 | ORDER BY strftime('%Y%W', start) DESC")?; 564 | let rows = weeks_stmnt.query_map(&[&project_id], |row| (row.get(0), row.get(1)))?; 565 | for row in rows { 566 | let (start, time): (DateTime, f64) = row?; 567 | let (y,w,_) = start.isoweekdate(); 568 | let start_of_week = NaiveDate::from_isoywd(y, w, Weekday::Mon); 569 | let end_of_week = NaiveDate::from_isoywd(y, w, Weekday::Sun); 570 | let time_str = format_time(time); 571 | println!("{}-{}\t{}", start_of_week.format("%d/%m/%y"), end_of_week.format("%d/%m/%y"), time_str); 572 | } 573 | Ok(()) 574 | } 575 | 576 | fn main() { 577 | env_logger::init().unwrap(); 578 | 579 | let mut conn = open_connection().unwrap(); 580 | let matches = App::new("Timmy") 581 | .version("0.1") 582 | .author("Matthew Hall") 583 | .about("Time tracker") 584 | .subcommand(SubCommand::with_name("new") 585 | .about("Creates a new project") 586 | .arg(Arg::with_name("NAME") 587 | .help("the project name") 588 | .required(true)) 589 | .arg(Arg::with_name("customer") 590 | .short("c") 591 | .long("customer") 592 | .takes_value(true)) 593 | .arg(Arg::with_name("tags") 594 | .short("t") 595 | .long("tags") 596 | .help("comma separated list of tags") 597 | .takes_value(true))) 598 | .subcommand(SubCommand::with_name("finish") 599 | .about("Makes a project inactive") 600 | .arg(Arg::with_name("NAME") 601 | .help("the project name") 602 | .required(true))) 603 | .subcommand(SubCommand::with_name("restart") 604 | .about("Reactivates a project") 605 | .arg(Arg::with_name("NAME") 606 | .help("the project name") 607 | .required(true))) 608 | .subcommand(SubCommand::with_name("track") 609 | .about("Start tracking a time period") 610 | .arg(Arg::with_name("PROJECT") 611 | .help("the project to start tracking time for") 612 | .required(true)) 613 | .arg(Arg::with_name("description") 614 | .short("d") 615 | .long("description") 616 | .help("a description of what you will do in the timeperiod") 617 | .takes_value(true)) 618 | .arg(Arg::with_name("start") 619 | .short("s") 620 | .long("start") 621 | .help("When to track from") 622 | .takes_value(true)) 623 | .arg(Arg::with_name("end") 624 | .short("e") 625 | .long("end") 626 | .help("When to end") 627 | .takes_value(true) 628 | .requires("start")) 629 | .arg(Arg::with_name("no program") 630 | .short("n") 631 | .long("noprogram") 632 | .help("Don't track program usage"))) 633 | .subcommand(SubCommand::with_name("git") 634 | .about("go through each time period and store the commits that happened during that \ 635 | time. timmy track automatically does this when you quit it for that \ 636 | time period. This command is useful if you've modified your git history \ 637 | in some way or you ran timmy track in the wrong directory.") 638 | .arg(Arg::with_name("PROJECT") 639 | .help("the project to assign the commits to") 640 | .required(true))) 641 | .subcommand(SubCommand::with_name("projects") 642 | .about("List the projects") 643 | .arg(Arg::with_name("all") 644 | .help("Show all projects, including inactive ones") 645 | .short("a") 646 | .long("all"))) 647 | .subcommand(SubCommand::with_name("project") 648 | .about("Show a project") 649 | .arg(Arg::with_name("NAME") 650 | .help("the project to show") 651 | .required(true)) 652 | .arg(Arg::with_name("since") 653 | .short("s") 654 | .long("since") 655 | .help("the date and time from which to show activity") 656 | .takes_value(true) 657 | .conflicts_with("short")) 658 | .arg(Arg::with_name("until") 659 | .short("u") 660 | .long("until") 661 | .help("the date and time until which to show activity") 662 | .takes_value(true) 663 | .conflicts_with("short")) 664 | .arg(Arg::with_name("week") 665 | .short("w") 666 | .long("week") 667 | .help("show activity in the past week") 668 | .conflicts_with_all(&["since", "until"])) 669 | .arg(Arg::with_name("short") 670 | .long("short") 671 | .help("omit the recent activity"))) 672 | .subcommand(SubCommand::with_name("weeks") 673 | .about("show time spent per week") 674 | .arg(Arg::with_name("PROJECT") 675 | .help("the project to show") 676 | .required(true)) 677 | .arg(Arg::with_name("short") 678 | .long("short") 679 | .help("show the short view"))) 680 | .get_matches(); 681 | 682 | let res = if let Some(matches) = matches.subcommand_matches("new") { 683 | create_project(&mut conn, 684 | matches.value_of("NAME").unwrap(), 685 | matches.value_of("customer"), 686 | matches.value_of("tags").unwrap_or("".into())) 687 | } else if let Some(matches) = matches.subcommand_matches("finish") { 688 | finish_project(&mut conn, matches.value_of("NAME").unwrap()) 689 | } else if let Some(matches) = matches.subcommand_matches("restart") { 690 | restart_project(&mut conn, matches.value_of("NAME").unwrap()) 691 | } else if let Some(matches) = matches.subcommand_matches("track") { 692 | track(&mut conn, 693 | matches.value_of("PROJECT").unwrap(), 694 | matches.value_of("description"), 695 | matches.value_of("start"), 696 | matches.value_of("end"), 697 | matches.is_present("no program")) 698 | } else if let Some(matches) = matches.subcommand_matches("git") { 699 | git(&mut conn, matches.value_of("PROJECT").unwrap()) 700 | } else if let Some(matches) = matches.subcommand_matches("projects") { 701 | projects(&mut conn, matches.is_present("all")) 702 | } else if let Some(matches) = matches.subcommand_matches("project") { 703 | project(&mut conn, 704 | matches.value_of("NAME").unwrap(), 705 | matches.is_present("week"), 706 | matches.value_of("since"), 707 | matches.value_of("until"), 708 | matches.is_present("short")) 709 | } else if let Some(matches) = matches.subcommand_matches("weeks") { 710 | if matches.is_present("short") { 711 | short_weeks(&mut conn, matches.value_of("PROJECT").unwrap()) 712 | } else { 713 | weeks(&mut conn, matches.value_of("PROJECT").unwrap()) 714 | } 715 | } else { 716 | unreachable!(); 717 | }; 718 | match res { 719 | Ok(()) => {} 720 | Err(Error::ProjectNotFound(p)) => println!("Project {} not found", p), 721 | Err(Error::ProjectAlreadyExists(p)) => println!("Project {} already exists", p), 722 | Err(Error::Git) => println!("No git repository found"), 723 | Err(Error::Sqlite(e)) => { 724 | println!("There was a problem with the database"); 725 | debug!("{:?}", e); 726 | }, 727 | Err(Error::InvalidDateTime(s)) => println!("Could not parse {}", s), 728 | Err(Error::InactiveProject(p)) => println!("Project {} is inactive", p), 729 | } 730 | } 731 | -------------------------------------------------------------------------------- /src/tables.rs: -------------------------------------------------------------------------------- 1 | use std::iter; 2 | 3 | #[derive(Debug, Clone)] 4 | pub enum CellType { 5 | Separator, 6 | Data(String), 7 | } 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct Cell { 11 | typ: CellType, 12 | border_left: String, 13 | border_right: String, 14 | } 15 | 16 | impl Cell { 17 | pub fn new_left_bordered(t: CellType, border: &str) -> Cell { 18 | Cell {typ: t, border_left: border.into(), border_right: "".into()} 19 | } 20 | 21 | pub fn new_both_bordered(t: CellType, left: &str, right: &str) -> Cell { 22 | Cell {typ: t, border_left: left.into(), border_right: right.into()} 23 | } 24 | 25 | pub fn new_right_bordered(t: CellType, border: &str) -> Cell { 26 | Cell {typ: t, border_left: "".into(), border_right: border.into()} 27 | } 28 | 29 | fn print(&self, width: usize) { 30 | let middle = match self.typ { 31 | CellType::Separator => iter::repeat("─").take(width+2).collect(), 32 | CellType::Data(ref s) => { 33 | let to_pad = width - s.len(); 34 | let spaces: String = iter::repeat(" ").take(to_pad).collect(); 35 | format!(" {}{} ", s, spaces) 36 | }, 37 | }; 38 | print!("{}{}{}", self.border_left, middle, self.border_right); 39 | } 40 | 41 | fn len(&self) -> usize { 42 | match self.typ { 43 | CellType::Separator => 0, 44 | CellType::Data(ref s) => s.len(), 45 | } 46 | } 47 | } 48 | 49 | pub struct Table { 50 | rows: Vec>, 51 | cols: usize, 52 | } 53 | 54 | impl Table { 55 | pub fn with_headers(headers: Vec) -> Table { 56 | let mut table = Table {rows: vec![], cols: headers.len()}; 57 | table.add_border_top(); 58 | table.add_simple(headers); 59 | table.add_full_separator(); 60 | table 61 | } 62 | 63 | pub fn add_row(&mut self, row: Vec) { 64 | self.rows.push(row); 65 | } 66 | 67 | pub fn add_simple(&mut self, data: Vec) { 68 | let len = data.len(); 69 | let cells = 70 | data.into_iter() 71 | .enumerate() 72 | .map(|(i, data)| { 73 | if i != len-1 { 74 | Cell::new_left_bordered(CellType::Data(data), "│") 75 | } else { 76 | Cell::new_both_bordered(CellType::Data(data), "│", "│") 77 | } 78 | }) 79 | .collect(); 80 | self.rows.push(cells); 81 | } 82 | 83 | pub fn add_full_separator(&mut self) { 84 | self.add_full_separator_custom("├", "┼", "┤"); 85 | } 86 | 87 | pub fn add_border_top(&mut self) { 88 | self.add_full_separator_custom("┌", "┬", "┐"); 89 | } 90 | 91 | pub fn add_border_bottom(&mut self) { 92 | self.add_full_separator_custom("└", "┴", "┘"); 93 | } 94 | 95 | pub fn add_full_separator_custom(&mut self, left: &str, middle: &str, right: &str) { 96 | let mut cells = vec![]; 97 | let middle_cell = Cell::new_left_bordered(CellType::Separator, middle); 98 | if self.cols == 1 { 99 | cells.push(Cell::new_both_bordered(CellType::Separator, left, right)); 100 | } else { 101 | cells.push(Cell::new_left_bordered(CellType::Separator, left)); 102 | } 103 | let mut middle_cells: Vec = (0..self.cols-2).map(|_| middle_cell.clone()).collect(); 104 | cells.append(&mut middle_cells); 105 | if self.cols != 1 { 106 | cells.push(Cell::new_both_bordered(CellType::Separator, middle, right)); 107 | } 108 | self.rows.push(cells); 109 | } 110 | 111 | pub fn print(&self) { 112 | let max_lengths: Vec = (0..self.cols) 113 | .map(|i| { 114 | let lens = self.rows.iter().map(|row| row[i].len()); 115 | lens.max().unwrap() 116 | }) 117 | .collect(); 118 | 119 | for row in &self.rows { 120 | for (i, cell) in row.iter().enumerate() { 121 | cell.print(max_lengths[i]); 122 | } 123 | println!(""); 124 | } 125 | } 126 | } 127 | 128 | -------------------------------------------------------------------------------- /tests/chronny.rs: -------------------------------------------------------------------------------- 1 | extern crate chrono; 2 | extern crate timmy; 3 | 4 | use chrono::*; 5 | use timmy::chronny::*; 6 | 7 | fn now() -> DateTime { 8 | DateTime::parse_from_rfc3339("2016-08-23T16:30:00+01:00").unwrap() 9 | } 10 | 11 | #[test] 12 | fn test_now() { 13 | assert_eq!(parse_datetime("now", now()), Some(now())); 14 | assert_eq!(parse_datetime("today", now()), Some(now())); 15 | } 16 | 17 | #[test] 18 | fn test_absolute_date() { 19 | let yesterday = now() - Duration::days(1); 20 | let first = DateTime::parse_from_rfc3339("2016-08-01T16:30:00+01:00").unwrap(); 21 | assert_eq!(parse_datetime("yesterday", now()), Some(yesterday)); 22 | assert_eq!(parse_datetime("01/08/16", now()), Some(first)); 23 | assert_eq!(parse_datetime("01/08/2016", now()), Some(first)); 24 | assert_eq!(parse_datetime("1/8/2016", now()), Some(first)); 25 | assert_eq!(parse_datetime("01/08", now()), Some(first)); 26 | } 27 | 28 | #[test] 29 | fn test_relative_date() { 30 | let yesterday = now() - Duration::days(1); 31 | let three_days_ago = now() - Duration::days(3); 32 | let in_four_days = now() + Duration::days(4); 33 | assert_eq!(parse_datetime("1 day ago", now()), Some(yesterday)); 34 | assert_eq!(parse_datetime("3 days ago", now()), Some(three_days_ago)); 35 | assert_eq!(parse_datetime("in 4 days", now()), Some(in_four_days)); 36 | } 37 | 38 | #[test] 39 | fn test_absolute_time() { 40 | let in_one_hour = now() + Duration::hours(1); 41 | let two_hours_ago = now() - Duration::hours(2); 42 | assert_eq!(parse_datetime("17:30", now()), Some(in_one_hour)); 43 | assert_eq!(parse_datetime("14:30", now()), Some(two_hours_ago)); 44 | } 45 | 46 | #[test] 47 | fn test_relative_time() { 48 | let in_one_hour = now() + Duration::hours(1); 49 | let in_thirty_mins = now() + Duration::minutes(30); 50 | let three_hours_ago = now() - Duration::hours(3); 51 | assert_eq!(parse_datetime("in 1 hr", now()), Some(in_one_hour)); 52 | assert_eq!(parse_datetime("in 1 hours", now()), Some(in_one_hour)); 53 | assert_eq!(parse_datetime("in 30 mins", now()), Some(in_thirty_mins)); 54 | assert_eq!(parse_datetime("in 30 minutes", now()), Some(in_thirty_mins)); 55 | assert_eq!(parse_datetime("3 hrs ago", now()), Some(three_hours_ago)); 56 | } 57 | 58 | #[test] 59 | fn test_kitchen_sink() { 60 | let yesterday_two = now() - Duration::days(1) - Duration::hours(2) - Duration::minutes(30); 61 | assert_eq!(parse_datetime("yesterday 14:00", now()), Some(yesterday_two)); 62 | } 63 | --------------------------------------------------------------------------------