├── .gitignore ├── .travis.yml ├── History.md ├── Makefile ├── Readme.md ├── Tips.md ├── bin └── timetip ├── index.js ├── lib ├── color.js ├── date_parser.js ├── formatter.js ├── helpers.js ├── reporters │ ├── default.js │ ├── json.js │ └── tmux.js ├── spec_parser.js ├── summarize.js └── time_log.js ├── man ├── timetip.1 ├── timetip.1.html └── timetip.1.md ├── package.json ├── support └── screenshot.png ├── tasks.taskpaper ├── test ├── default_reporter │ ├── day.coffee │ └── test.coffee ├── etc │ ├── color.coffee │ ├── date_parser.coffee │ ├── formatter.coffee │ ├── helpers.coffee │ ├── spec_parser.coffee │ └── summarize.coffee ├── mocha.opts ├── setup.coffee └── time_logs │ ├── dates.coffee │ ├── format.coffee │ ├── get.coffee │ ├── push.coffee │ ├── range.coffee │ ├── sanity.coffee │ ├── sort.coffee │ ├── summaries.coffee │ └── test.coffee ├── vendor └── sugar-date.js └── www └── timetip.1.html /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: ["0.10"] 3 | notifications: 4 | email: 5 | recipients: 6 | - dropbox+travis@ricostacruz.com 7 | on_success: change 8 | on_failure: change 9 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | ## [v0.1.1] - May 21, 2015 2 | 3 | - Fix `timetip edit` crashing when no `$EDITOR` is found 4 | - Add manpage 5 | 6 | ## v0.1.0 - Sep 25, 2013 7 | 8 | Initial version. 9 | 10 | [v0.1.1]: https://github.com/rstacruz/timetip/compare/v0.1.0...v0.1.1 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Development notes: 2 | # 3 | # * install ronn (gem install ronn) 4 | # * `make` will rebuild man files 5 | # * `make deploy` will deploy site 6 | # * `man ./man/timetip.1` to view the man page 7 | # 8 | all: \ 9 | man/timetip.1 \ 10 | www/timetip.1.html 11 | 12 | man/%: man/%.md 13 | ronn $< 14 | 15 | www/%.html: man/% 16 | mv $<.html $@ 17 | 18 | deploy: 19 | git subtree push --prefix www origin gh-pages 20 | 21 | .PHONY: man deploy 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | timetip 2 | ======= 3 | 4 | Deliciously-minimal time tracker for the command-line. Built on Node.js. 5 | 6 | ![Screenshot](https://github.com/rstacruz/timetip/raw/master/support/screenshot.png) 7 | 8 | - **Frictionless time logging.** 9 | [>](#get-started) Start by typing `t shopping in the grocery`. 10 | 11 | - **Everything in the terminal.** 12 | [>](#install) It's a Node.js command-line app that runs anywhere Node can. 13 | Even Windows! 14 | 15 | - **Natural language parsing.** 16 | [>](#--help) All commands are composed in such a way as if you're talking to timetip. 17 | Examples: `t stop` or `t Meeting 3 minutes ago`. 18 | 19 | - **For humans who love text editors.** 20 | [>](#storage) Logs are stored in a simple human-editable format that you're 21 | encouraged to edit yourself. 22 | 23 | - **Portable data.** 24 | [>](#exporting) Export to json painlessly. You can also use it as an 25 | [npm package](#programmatic-usage) to parse out your files. 26 | 27 | [![Status](https://travis-ci.org/rstacruz/timetip.png)](https://travis-ci.org/rstacruz/timetip) 28 | 29 | See the [man page] for more usage notes and examples. 30 | 31 | ## Install 32 | 33 | Install it via npm: 34 | 35 | npm install -g timetip 36 | 37 | To make things easier, add this to your `~/.profile`. (optional) 38 | 39 | alias t="timetip" 40 | 41 | *Note: the examples below assume that you have the alias above. If you choose 42 | not to to use it, assume that the `t` below is `timetip`.* 43 | 44 | ## Get started 45 | 46 | **Log** a task by typing `t `. (By convention, the first word 47 | is ideally the project name). For instance: 48 | 49 | ~~~ sh 50 | $ t Jsconf email speakers 51 | # ...starts the task "Jsconf email speakers" 52 | ~~~ 53 | 54 | **Stop** it using `t stop`: 55 | 56 | ~~~ sh 57 | $ t stop 58 | # ...stops the current task 59 | ~~~ 60 | 61 | You may also issue a reason to stop: 62 | 63 | ~~~ sh 64 | $ t stop coffee break 65 | # ...stops the current task for the reason of "coffee break" 66 | ~~~ 67 | 68 | **View the status** with simply `t`: 69 | 70 | ~~~ sh 71 | $ t 72 | 73 | september 18, 2013 total 1h 15m 74 | 75 | 1:30pm Jsconf email speakers 52m 76 | 2:22pm ⋅ coffee break 8m 77 | 2:30pm Jsconf check ticket sales 1h 5m 78 | 3:35pm ⋅ break 14m 79 | 3:49pm ✓ Errands grocery ⋅⋅ now 80 | ~~~ 81 | 82 | ## Storage 83 | 84 | Everything is stored in a human-editable format into `~/.timelogs` (use `--file` 85 | to change the location). You're encouraged to add, edit, delete and 86 | rearrange entries using your favorite text editor. 87 | 88 | ~~~ ini 89 | $ cat ~/.timelogs 90 | 91 | [2013-09-16 mon] 92 | 1:14pm = Misc write emails 93 | 2:42pm = Misc balance checkbook 94 | 3:00pm = 95 | 96 | [2013-09-18 wed] 97 | 3:14pm = Jsconf email speakers 98 | 3:59pm = -- coffee break 99 | 4:09pm = Jsconf check ticket sales 100 | 4:25pm = Errands grocery 101 | ~~~ 102 | 103 | You can use `t edit` to open in in your default text editor ($EDITOR). 104 | 105 | ## Looking up entries 106 | 107 | **View entries from any date** by using `t `. It supports natural language 108 | parsing: 109 | 110 | $ t yesterday 111 | $ t aug 2 112 | $ t last friday 113 | 114 | **Query a date range** by using `t - `: 115 | 116 | $ t last monday - last friday 117 | $ t aug 2 - aug 10 118 | $ t last month - now 119 | 120 | ## Exporting 121 | 122 | **Export your data** by using the alternative reporters (`--reporter`). The 123 | *json* reporter exports your data as a Json object: 124 | 125 | ~~~ js 126 | $ t all --reporter json 127 | { 128 | entries: [ 129 | { 130 | type: "task", 131 | date: "2013-09-18T05:32:47.333Z", 132 | endDate: "2013-09-18T05:32:47.333Z", 133 | duration: 60000, 134 | project: "Jsconf", 135 | task: "Email speakers" 136 | }, ... 137 | ] 138 | } 139 | ~~~ 140 | 141 | ## Programmatic usage 142 | 143 | Want to easily parse time logs? Use it as a Node.js module. See the 144 | [source][time_log.js] for more details. 145 | 146 | ~~~ js 147 | var TimeLog = require('timetip').TimeLog; 148 | var log = new TimeLog('~/.timelogs'); 149 | 150 | var day = log.get('2013-09-02'); 151 | var day = log.get(new Date(2013, 8, 2)); 152 | 153 | day.entries 154 | day.last 155 | day.summary 156 | ~~~ 157 | 158 | ## Acknowledgements 159 | 160 | © 2013, Rico Sta. Cruz. Released under the [MIT License]. 161 | 162 | [MIT License]: http://www.opensource.org/licenses/mit-license.php 163 | [time_log.js]: lib/time_log.js 164 | [man page]: http://rstacruz.github.io/timetip/timetip.1.html 165 | -------------------------------------------------------------------------------- /Tips.md: -------------------------------------------------------------------------------- 1 | # Extras 2 | 3 | Tmux status bar 4 | --------------- 5 | 6 | There's a crappy Tmux status bar reporter using: 7 | 8 | $ t -R tmux 9 | 10 | You can show it in your Tmux status bar using: 11 | 12 | ~~~ sh 13 | # ~/.tmux.conf 14 | set -g status-right ' #(timetip -R tmux) ' 15 | ~~~ 16 | 17 | Vim highlighting 18 | ---------------- 19 | 20 | Want to syntax-highlight your time logs properly? Add this on top of your 21 | `~/.timelogs`: 22 | 23 | # = vim:ft=dosini 24 | 25 | It's not a file comment, but rather a key of `#` with a value of `vim:...`. It's 26 | a hack since comments (`;`) are stripped out when logging tasks :) 27 | 28 | Multiple time logs 29 | ------------------ 30 | 31 | You may prefer to keep 2 (or more) time sheets: say, one for work and one for 32 | home. 33 | 34 | One way to accomplish this is to keep `~/.timelogs` as a symlink instead of a 35 | file, and simply switch out the file it links to as you need them. Here's a 36 | small bash script: 37 | 38 | ~~~ sh 39 | # ~/.profile 40 | alias t="timetap" 41 | alias t.home="ln -nfs ~/Dropbox/Timesheets/home.txt ~/.timelogs ; t" 42 | alias t.work="ln -nfs ~/Dropbox/Timesheets/work.txt ~/.timelogs ; t" 43 | ~~~ 44 | 45 | You can now simply: 46 | 47 | ~~~ sh 48 | $ t.home # ..switch to the 'home' sheet 49 | $ t Do dishes # ..logs a task to the 'home' sheet 50 | 51 | $ t.work # ..switch to the 'work' sheet 52 | $ t Client meeting # ..logs a task to the 'work' sheet 53 | ~~~ 54 | 55 | Multiple time logs (alternative) 56 | -------------------------------- 57 | 58 | You can also set up 2 aliases that log to different files. 59 | 60 | ~~~ sh 61 | # ~/.profile 62 | alias thome="timetap --file ~/Dropbox/Timesheets/home.txt" 63 | alias twork="timetap --file ~/Dropbox/Timesheets/work.txt" 64 | ~~~ 65 | 66 | You can then: 67 | 68 | ~~~ sh 69 | $ thome Do dishes # ..log to the 'home' sheet 70 | $ twork Client meeting # ..log to the 'work' sheet 71 | 72 | $ thome # ..view entries in 'home' 73 | $ twork # ..view entries in 'work' 74 | ~~~ 75 | 76 | Integration with pomo.js 77 | ------------------------ 78 | 79 | Want to see a timer while you work? Try [pomo.js]. You can do `t Start things ; 80 | pomojs -d 30 ; t stop` to use a timer with timetap. 81 | 82 | Bonus: you can set up a bash function for this. 83 | 84 | ~~~ sh 85 | # ~/.profile 86 | tpomo() { 87 | t $@ 88 | pomojs -d 30 $@ 89 | t stop 90 | } 91 | ~~~ 92 | 93 | [pomo.js]: https://github.com/rstacruz/pomo.js 94 | -------------------------------------------------------------------------------- /bin/timetip: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // vim:ft=javascript 3 | 4 | var cli = require('commander'); 5 | var extend = require('util')._extend; 6 | var _ = require('underscore'); 7 | var f = require('../lib/formatter'); 8 | var T = require('../lib/reporters/default').theme; 9 | var S = require('../lib/reporters/default').symbols; 10 | var openEditor = require('../lib/helpers').openEditor; 11 | var printUsage = require('../lib/helpers').printUsage; 12 | var duration = require('../lib/helpers').duration; 13 | var isToday = require('../lib/helpers').isToday; 14 | 15 | cli 16 | .version(require('../package').version) 17 | .option('-f, --file ', 'the data file [~/.timelogs]', '~/.timelogs') 18 | .option('-R, --reporter ', 'use reporter (default|json)', 'default') 19 | .option('-u, --use ', 'use node module ') 20 | .option('--no-color', 'disable colors', true) 21 | .on('--help', function() { 22 | if (!cli.color) process.env.NO_COLOR = true; 23 | 24 | function us(str) { printUsage(cli._name, str, "1"); } 25 | function ex(str) { printUsage(cli._name, str, "0"); } 26 | function p(str) { console.log(str); } 27 | 28 | p(' Shortcuts:'); 29 | us('t # start working (alias: `start`)'); 30 | us('t - [] # stop working (alias: `stop`)'); 31 | us('t # show entries for the date (alias: `show`)'); 32 | us('t # show today (alias: `show today`)'); 33 | p(''); 34 | p(' Commands:'); 35 | us('t start # start working'); 36 | us('t stop [] # stop working'); 37 | us('t show # show entries for the date or range'); 38 | us('t dates # show dates with time logs'); 39 | us('t edit # open in text editor'); 40 | us('t summary [] # show summary of entries'); 41 | p(''); 42 | p(' Examples:'); 43 | ex('t Myproject stuff # start working on "myproject stuff"'); 44 | ex('t Meeting 3m ago # start working on "meeting" 3 minutes ago'); 45 | ex('t stop # stop the current task'); 46 | ex('t stop lunch break # stop the current task and log the reason'); 47 | ex('t Sep 2 # show entries from a day'); 48 | ex('t May 5 - May 10 # show entries a range'); 49 | ex('t 4 days ago # show entries from 4 days ago'); 50 | p(''); 51 | p(' Also see `man timetip` for more info and examples.'); 52 | }); 53 | 54 | var TimeLog = require('../lib/time_log'); 55 | var Summarize = require('../lib/summarize'); 56 | 57 | /* 58 | * The controller 59 | */ 60 | 61 | var log, reporter; 62 | 63 | extend(cli, { 64 | status: function() { 65 | var date = new Date(); 66 | var data = this.getLogs(date); 67 | if (!data.last) return this.noLogs(date); 68 | 69 | this.getReporter().day(data); 70 | }, 71 | 72 | dates: function() { 73 | var r = this.getReporter(); 74 | if (!r.dates) return; 75 | 76 | var dates = this.getLog().dates(); 77 | if (dates.length === 0) { 78 | this._tip(''); 79 | this._err(f('no entries logged yet.')); 80 | this.gettingStarted(); 81 | return; 82 | } 83 | 84 | r.dates(dates); 85 | }, 86 | 87 | query: function(date) { 88 | if (!date) 89 | this._die('invalid date.'); 90 | 91 | var rep = this.getReporter(); 92 | 93 | if (date === 'all') { 94 | var data = this.getRange(); 95 | if (rep.range) rep.range(data); 96 | } 97 | else if (date.constructor === Array) { /* Range */ 98 | var data = this.getRange(date); 99 | if (rep.range) rep.range(data); 100 | } 101 | else { /* Day */ 102 | var data = this.getLogs(date); 103 | rep.day(data); 104 | } 105 | }, 106 | 107 | // Start or stop 108 | log: function(spec) { 109 | if (!spec) return this.invalidUsage(); 110 | 111 | this.getLog().push(spec).save(); 112 | this.getReporter().day(this.getLog().get(spec.date), { added: true }); 113 | }, 114 | 115 | /* ---- */ 116 | 117 | noLogs: function(date) { 118 | console.error(''); 119 | 120 | if (isToday(date)) { 121 | this._ok ('nothing logged yet today.'); 122 | this.gettingStarted(); 123 | } 124 | else 125 | this._err(f('nothing logged for %s', 126 | T.accent(isToday(date) ? 'today' : date.format('{month} {dd}, {yyyy}')))); 127 | 128 | process.exit(0); 129 | }, 130 | 131 | gettingStarted: function() { 132 | this._tip(f('start now! try: %s %s', 133 | T.accent(cli._name), 134 | T.accent(""))); 135 | this._tip(''); 136 | this._tip("see `"+cli._name+" --help` for more info."); 137 | }, 138 | 139 | _ok: function(str) { 140 | console.error(f(' %s %s', T.now(S.check), str)); 141 | }, 142 | 143 | _die: function(str) { 144 | this._tip(''); 145 | this._err(str); 146 | process.exit(26); 147 | }, 148 | 149 | _err: function(str) { 150 | console.error(f(' %s %s', T.err(S.cross), str)); 151 | }, 152 | 153 | _tip: function(str) { 154 | console.error(' '+str); 155 | }, 156 | 157 | /** 158 | * Returns logs for a given `date` or dies with a message 159 | * @private 160 | */ 161 | 162 | getLogs: function(date) { 163 | var data = this.getLog().get(date); 164 | if (!data) return this.noLogs(date); 165 | return data; 166 | }, 167 | 168 | getRange: function(range) { 169 | var data = this.getLog().range(range); 170 | if (!data) return this.noLogs(range); 171 | return data; 172 | }, 173 | 174 | getLog: function() { 175 | if (log) return log; 176 | 177 | log = new TimeLog(cli.file); 178 | return log; 179 | }, 180 | 181 | /** 182 | * Returns the reporter asked for in `--reporter` 183 | * @private 184 | */ 185 | 186 | getReporter: function() { 187 | if (reporter) return reporter; 188 | 189 | var Reporters = require('..').reporters; 190 | var Reporter = Reporters[cli.reporter]; 191 | 192 | if (!Reporter) 193 | this._die("unknown reporter `"+cli.reporter+"`"); 194 | 195 | reporter = new Reporter(this.getLog()); 196 | return reporter; 197 | }, 198 | 199 | summary: function(date) { 200 | var data = this.getLog().range(date); 201 | var summary = Summarize(data); 202 | var rep = this.getReporter(); 203 | 204 | if (rep.summary) rep.summary(summary, data); 205 | }, 206 | 207 | run: function(argv) { 208 | cli.parse(argv); 209 | 210 | var DateParser = require('../lib/date_parser'); 211 | var SpecParser = require('../lib/spec_parser'); 212 | var cmd = cli.args[0] || ''; 213 | var rest = cli.args.slice(1).join(' '); 214 | var args = cli.args.join(' '); 215 | 216 | var date, spec; 217 | 218 | if (!cli.color) 219 | process.env.NO_COLOR = true; 220 | if (cli.use) { 221 | cli.use.split(',').forEach(function(mod) { require(mod)(require('..')); }); 222 | } 223 | 224 | if (cmd === 'edit') 225 | openEditor(cli.file); 226 | else if (cmd === 'dates' || cmd === 'days') 227 | this.dates(); 228 | else if (cmd === 'stop' || cmd === '-') 229 | this.log(SpecParser(rest, { mode: 'break' })); 230 | else if (cmd === 'start') 231 | this.log(SpecParser(rest)); 232 | else if (cmd === 'all' || args === 'show all') 233 | this.query('all'); 234 | else if (cmd === 'show' && rest === '') 235 | this.query(new Date()); 236 | else if (cmd === 'show') 237 | this.query(DateParser.any(rest)); 238 | else if (cmd === 'sum' || cmd === 'summary') 239 | this.summary(DateParser.any(rest)); 240 | else if (cmd === '') 241 | this.status(); 242 | else if (date = DateParser.any(args)) 243 | this.query(date); 244 | else if (spec = SpecParser(args)) 245 | this.log(spec); 246 | } 247 | }); 248 | 249 | cli.run(process.argv); 250 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | TimeLog: require('./lib/time_log'), 3 | reporters: { 4 | 'default': require('./lib/reporters/default'), 5 | 'json': require('./lib/reporters/json'), 6 | 'tmux': require('./lib/reporters/tmux') 7 | }, 8 | utils: { 9 | formatter: require('./lib/formatter'), 10 | color: require('./lib/color') 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /lib/color.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ANSI colorizer. 3 | * 4 | * var c = require('color'); 5 | * 6 | * Use it as: 7 | * 8 | * c('blue', 'Hello'); 9 | * 10 | * You can use multiple flags (see `color.flags` for a list): 11 | * 12 | * c('bold red', 'Hello'); 13 | * c('white bgblue', 'Hello'); 14 | * c('underline red', 'Hello'); 15 | * 16 | * Or raw ANSI codes: 17 | * 18 | * c(34, 'Hello'); 19 | * 20 | * You can also use shortcuts: 21 | * 22 | * c.blue('Hello'); 23 | * 24 | * Or make your own theme with `.use()`: 25 | * 26 | * em = color.use('green'); 27 | * h1 = color.use('bold white'); 28 | * 29 | * h1('Books:'); 30 | * em("Cuckoo's Calling"); 31 | */ 32 | 33 | var color = module.exports = function(c, str) { 34 | c = color.ansi(c); 35 | 36 | if (!c || !color.enabled()) 37 | return str; 38 | else 39 | return "\033["+c+"m"+str+"\033[0m"; 40 | }; 41 | 42 | /** 43 | * Returns `true` if color is supposed to be on, or `false` if off. 44 | * By default, it checks the NO_COLOR env var. 45 | * 46 | * Hint: you can override this. 47 | */ 48 | 49 | color.enabled = function() { 50 | return ! process.env.NO_COLOR; 51 | }; 52 | 53 | /** 54 | * Great map of color names. 55 | */ 56 | 57 | color.flags = { 58 | clear : 0, 59 | bold : 1, 60 | reverse : 3, 61 | underline : 4, 62 | blink : 5, 63 | 64 | grey : 30, 65 | red : 31, 66 | green : 32, 67 | yellow : 33, 68 | blue : 34, 69 | magenta : 35, 70 | cyan : 36, 71 | white : 37, 72 | 73 | bggrey : 40, 74 | bgred : 41, 75 | bggreen : 42, 76 | bgyellow : 43, 77 | bgblue : 44, 78 | bgmagenta : 45, 79 | bgcyan : 46, 80 | bgwhite : 47 81 | }; 82 | 83 | /** 84 | * Converts a symbol string to an ansi color code. 85 | * 86 | * color.ansi('red') //=> 31 87 | * color.ansi('bold red') //=> 1;31 88 | */ 89 | 90 | color.ansi = function(str) { 91 | // Raw ansi code (32) 92 | if (typeof str === 'number') 93 | return str; 94 | 95 | // Raw ansi code as string ("32") 96 | if (typeof str === 'string' && str.match(/^\d/)) 97 | return str; 98 | 99 | // Multiple words ("bold red") 100 | if (typeof str === 'string' && str.indexOf(' ') > -1) 101 | return str.split(' ') 102 | .map(function(s) { return color.flags[s]; }) 103 | .join(';'); 104 | 105 | return color.flags[str]; 106 | }; 107 | 108 | /** 109 | * Functionizes a color. 110 | * 111 | * var accent = color.use('blue'); 112 | * accent('hello'); 113 | * 114 | * Example: 115 | * 116 | * var theme = { 117 | * em: color.use('green'), 118 | * h1: color.use('bold white') 119 | * }; 120 | * 121 | * console.log(theme.h1('Books:')); 122 | * console.log(theme.em("Cuckoo's Calling") + " by R. Galbraith"); 123 | */ 124 | 125 | color.use = function(c) { 126 | c = color.ansi(c); 127 | return function(str) { return color(c, str); }; 128 | }; 129 | 130 | 131 | /** 132 | * Expose `c.blue()` shortcuts (and so on). 133 | */ 134 | 135 | for (var name in color.flags) 136 | color[name] = color.use(color.flags[name]); 137 | -------------------------------------------------------------------------------- /lib/date_parser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses natural language dates. 3 | * 4 | * DateParser.any('2d ago') //=> Date 5 | * DateParser.any('yesterday - today') //=> [Date, Date] 6 | */ 7 | 8 | var DateParser = module.exports = {}; 9 | 10 | /** 11 | * Both 12 | */ 13 | 14 | DateParser.any = function(str) { 15 | return DateParser.range(str) || DateParser.date(str); 16 | }; 17 | 18 | /** 19 | * Single date 20 | * 21 | * date("2013-03-04 mon") //=> [object Date] 22 | * date("3:14pm", date) 23 | * date("??") //=> null 24 | */ 25 | 26 | DateParser.date = function(str, context) { 27 | require('../vendor/sugar-date'); 28 | if (str.getHours) str = date; 29 | 30 | str = str 31 | .replace(/(\d\d) (mon|tue|wed|thu|fri|sat|sun)$/g, function(_,d,s) { return d; }) 32 | .replace(/(\d+)d(\s|$)/g, function(_,d,s) { return d + " days"+s; }) 33 | .replace(/(\d+)h(\s|$)/g, function(_,d,s) { return d + " hours"+s; }); 34 | 35 | // Account for `context` 36 | if (context) str = context.format('{yyyy}-{MM}-{dd}') + ' ' + str; 37 | 38 | var date = Date.create(str); 39 | return isNaN(+date) ? null : date; 40 | }; 41 | 42 | /** 43 | * Range 44 | */ 45 | 46 | DateParser.range = function(str) { 47 | str = str.replace(/^since (.+)$/, '$1 - now'); 48 | var m = str.match(/^(.*?) (?:-|to) (.*?)$/); 49 | if (!m) return; 50 | 51 | var from = DateParser.date(m[1]); 52 | if (!from) return; 53 | 54 | var to = DateParser.date(m[2]); 55 | if (!to) return; 56 | 57 | return [from, to]; 58 | }; 59 | -------------------------------------------------------------------------------- /lib/formatter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Printf-style formatter. 3 | * 4 | * var f = require('formatter'); 5 | * 6 | * console.log(f("Hello %s", "John")); 7 | * 8 | * Features: 9 | * 10 | * - Supports most everything 11 | * - Padding (`f("%6s", "John") => " John") 12 | * - Named groups (`f("%(name)s", { name: "John" })`) 13 | * - It's ansi-color aware (wohoo!) 14 | */ 15 | 16 | var Fmt = module.exports = function() { 17 | return Fmt.format.apply(this, arguments); 18 | }; 19 | 20 | var types = /[sdifcx]/; 21 | var flags = /[ \-+0]/; 22 | 23 | // Token regexp 24 | // Separated into these parts: % - 2 .10 f 25 | var re = new RegExp( 26 | "%" + 27 | "(?:\\((.*?)\\))?" + // string index 28 | "(?:(\\d+)\\$)?" + // numeric index 29 | "("+flags.source+")?" + // flags 30 | "(\\d+)?" + // length 31 | "(?:\\.(\\d+))?" + // precision 32 | "("+types.source+")", // type 33 | "g"); 34 | 35 | /** 36 | * Performs formatting. 37 | */ 38 | 39 | Fmt.format = function(spec) { 40 | var args = arguments, i = 1; 41 | return spec.replace(re, 42 | function(full, strIndex, nIndex, flag, len, prec, type) { 43 | var src; 44 | 45 | if (strIndex) src = args[1][strIndex]; 46 | else if (nIndex) src = args[parseInt(nIndex, 10)]; 47 | else src = args[i++]; 48 | 49 | return Fmt.token(src, { 50 | flag: flag, 51 | length: (typeof len === 'string') ? parseInt(len, 10) : null, 52 | precision: (typeof prec === 'string') ? parseInt(prec, 10) : null, 53 | type: type, 54 | full: full 55 | }); 56 | }); 57 | }; 58 | 59 | /** 60 | * Processes a token. 61 | */ 62 | 63 | Fmt.token = function(src, token) { 64 | var text; 65 | 66 | var fn = this.flags[token.type]; 67 | 68 | // Account for `type` flags 69 | // Delegate it to the `.flag` methods, or revert to plain .toString() 70 | if (fn) 71 | text = fn.apply(this, arguments); 72 | else if (!src) 73 | text = ''; 74 | else 75 | text = src.toString(); 76 | 77 | // Account for length 78 | if (token.length) 79 | text = pad(text, token.length, token.flag); 80 | 81 | return text; 82 | }; 83 | 84 | Fmt.flags = { 85 | f: function(f, token) { return floatToString(f, token.precision); }, 86 | c: function(ch, token) { return String.fromCharCode(parseInt(ch, 10)); }, 87 | i: function(i, token) { i = Math.floor(i); return (token.flag === "+") ? "+"+i : ""+i; }, 88 | x: function(i, token) { return i.toString(16); } 89 | }; 90 | 91 | Fmt.flags.d = Fmt.flags.i; 92 | 93 | /** 94 | * Returns the length of a given string. 95 | * You can reimplement this. 96 | */ 97 | 98 | Fmt.len = function(str) { 99 | // Strip ansi codes 100 | return str.replace(/\033\[[\d;]*m/g, '').length; 101 | }; 102 | 103 | /** 104 | * Stringifies a float number. 105 | * @private 106 | * 107 | * floatToString(3.14159) //=> "3.14159" 108 | * floatToString(3.14159, 2) //=> "3.14" 109 | */ 110 | 111 | function floatToString(n, prec) { 112 | if (typeof prec === 'number' && prec > 0) { 113 | var x = Math.pow(10, prec); 114 | 115 | // Get left and right of decimal point 116 | var left = Math.floor(n); 117 | var right = (Math.round(n*x) / x).toString().split('.')[1]; 118 | 119 | // Pad by extra zeros 120 | if (right.length < prec) right += repeat('0', prec-right.length); 121 | 122 | return left + "." + right; 123 | } 124 | else 125 | return n.toString(); 126 | } 127 | 128 | /** 129 | * Repeats a given character `char` by `n` times. 130 | * @private 131 | */ 132 | 133 | function repeat(char, n) { 134 | var str = ''; 135 | for (var i=0; i= n) return str; 147 | 148 | // Calculate remainder 149 | var char = flag === "0" ? "0" : " "; 150 | var space = repeat(char, n-len); 151 | 152 | if (flag === "-") return str + space; 153 | else return space + str; 154 | } 155 | -------------------------------------------------------------------------------- /lib/helpers.js: -------------------------------------------------------------------------------- 1 | var Helpers = module.exports = {}; 2 | var f = require('./formatter'); 3 | 4 | /** 5 | * Converts `n` milliseconds into a duration string. 6 | */ 7 | 8 | Helpers.duration = function(n) { 9 | var flr = Math.floor; 10 | var secs = flr(n / 1000); 11 | var re = []; 12 | 13 | if (secs >= 3600) { var h = flr(secs/3600); re.push(f('%ih', h)); secs -= h*3600; } 14 | if (secs >= 60) { var m = flr(secs/60); re.push(f('%2im', m)); secs -= m*60; } 15 | 16 | return re.join(' '); 17 | }; 18 | 19 | /** 20 | * Print a usage field. 21 | * 22 | * printUsage('tw', 'tw stop # stops a task'); 23 | */ 24 | 25 | Helpers.printUsage = function(name, str, color) { 26 | var f = require('./formatter').format; 27 | var C = require('./color'); 28 | var m = str.match(/^[a-z]+ (?:((?:-+)?[a-z]*) )?(?:(.*?))? *# (.*)$/); 29 | 30 | var args = m[2].toLowerCase(); 31 | 32 | var text = m[3] 33 | .replace(/`(.*?)`/, function(_, a) { return C(color, a); }); 34 | 35 | var cmd = m[1] ? 36 | f("%s %s", name, m[1]) : name; 37 | 38 | console.log(f(' %-30s %s', 39 | f('%s %s', 40 | C(color, cmd), 41 | C.grey(args || '')), 42 | text)); 43 | }; 44 | 45 | /** 46 | * Opens a given file in the default editor. 47 | */ 48 | 49 | Helpers.openEditor = function(file) { 50 | var spawn = require('child_process').spawn; 51 | var bin = process.env.EDITOR || 'vim'; 52 | var cmd = bin+" "+file; 53 | 54 | // Vim hack to open the file and scroll to the last line 55 | // (`vim + ~/.timelogs`) 56 | if (bin.match(/vim?$/i)) cmd = bin+" + "+file; 57 | 58 | // Spawn 59 | var proc = spawn('sh', ['-c', cmd], { stdio: 'inherit' }); 60 | }; 61 | 62 | /** 63 | * Checks if a given date is today. 64 | */ 65 | 66 | Helpers.isToday = function(date, now) { 67 | if (!now) now = new Date(); 68 | now.setHours(0); 69 | now.setMinutes(0); 70 | now.setSeconds(0); 71 | now.setMilliseconds(0); 72 | 73 | var then = new Date(date); 74 | then.setHours(0); 75 | then.setMinutes(0); 76 | then.setSeconds(0); 77 | then.setMilliseconds(0); 78 | 79 | return +then === +now; 80 | }; 81 | 82 | /** 83 | * Sets an `object`s `key` to `value`, and ensures that it's in the correct 84 | * position. 85 | * 86 | * This ensures the sorted integrity of a given object. 87 | */ 88 | 89 | Helpers.sortedPush = function (object, key, value, comp) { 90 | var _ = require('underscore'); 91 | var re = {}; 92 | var keys = Object.keys(object); 93 | 94 | if (keys.length === 0) { 95 | re[key] = value; 96 | return re; 97 | } 98 | 99 | // Insert at given place in the begin/middle 100 | var idx = _.sortedIndex(keys, key, comp); 101 | for (var i=0; i 0) console.log(''); 121 | console.log(f("%s %s", T.now(S.tri), T.bold(year))); 122 | 123 | _.each(months, function(days, sMonth) { 124 | var line = '', c; 125 | 126 | _.range(1, 31).forEach(function(day) { 127 | var d = new Date(days.year, days.month, day); 128 | if (d > now) return; 129 | 130 | if (isWeekend(d)) c = T.err; 131 | else if (days[day]) c = T.bold; 132 | else c = T.mute; 133 | 134 | line += ' '; 135 | if (days[day]) line += c(day); 136 | else if (day < 10) line += c(S.dot); 137 | else line += c(S.dot+' '); 138 | }); 139 | 140 | console.log(f(' %s%s', T.accent(sMonth), line)); 141 | }); 142 | }); 143 | }; 144 | 145 | function hashifyDates(dates) { 146 | // Populate the hash 147 | var hash = {}; 148 | dates.forEach(function(d) { 149 | var year = d.format('{yyyy}'); 150 | var month = d.format('{mon}'); 151 | var day = d.getDate(); 152 | 153 | if (!hash[year]) hash[year] = {}; 154 | if (!hash[year][month]) { 155 | hash[year][month] = { 156 | year: d.getFullYear(), 157 | month: d.getMonth() 158 | }; 159 | } 160 | 161 | // Push date 162 | hash[year][month][day] = true; 163 | }); 164 | 165 | return hash; 166 | } 167 | 168 | function isWeekend(d) { 169 | var day = d.getDay(); 170 | return (day === 0); 171 | // return (day === 0 || day === 6); 172 | } 173 | 174 | /** 175 | * @private 176 | */ 177 | 178 | Reporter.prototype._entry = function(entry, options) { 179 | var time = entry.date.format(this.log.formats.time); 180 | var prefix='', dur='', task, suffix=''; 181 | 182 | // --- Prefix 183 | if (options && options.now) 184 | prefix = C.green(S.check); 185 | 186 | // --- Task entry 187 | if (entry.type === 'task') /* task */ 188 | task = f("%s %s", 189 | T.bold(entry.project), 190 | entry.task||''); 191 | else if (entry.reason) /* break with reason */ 192 | task = T.mute(f("%s %s", 193 | S.dash, 194 | entry.reason)); 195 | else /* break */ 196 | task = T.mute(S.dash); 197 | 198 | // --- Duration 199 | if (entry.duration) 200 | dur = duration(entry.duration); 201 | 202 | // -- Suffix 203 | if (options && options.now && dur.length) 204 | suffix = T.now(" +"); 205 | 206 | // -- Duration color 207 | if (options && options.now && dur.length === 0) 208 | dur = T.now(f("%s %s", S.dot+S.dot, 'now')); 209 | else if (entry.type === 'task') 210 | dur = T.bold(dur); 211 | else 212 | dur = T.mute(dur); 213 | 214 | pf('%9s %1s %-48s%10s%s', 215 | T.mute(time), 216 | prefix, 217 | task, 218 | dur, 219 | suffix); 220 | }; 221 | 222 | /** 223 | * For `t summary` 224 | * 225 | * - `summary` - (Object) the result of Summarize() 226 | * - `data` - (Object) gotten from TimeLog#range() 227 | */ 228 | 229 | Reporter.prototype.summary = function(summary, data) { 230 | console.log(''); 231 | var projects = summary.projects; 232 | 233 | var heading = ''; 234 | if (!data.range) 235 | heading = 'all entries'; 236 | else if (data.range[1]) 237 | heading = f('%s - %s', 238 | data.range[0].format('{mon} {d} {dow}'), 239 | data.range[1].format('{mon} {d} {dow}')); 240 | else if (data.range.getHours) 241 | heading = f('%s', 242 | data.range.format('{mon} {d} {dow}')); 243 | 244 | pf(' %s', T.accent(heading)); 245 | 246 | if (_.size(projects) === 0) { 247 | pf(' %s', "no results"); 248 | } 249 | else { 250 | pf(' %s', T.accent('projects')); 251 | _.each(projects, function(project, name) { 252 | // var dur = f('%ih', project.duration / 3600000); 253 | // if (dur === '0h') dur = '<1h'; 254 | var dur = duration(project.duration); 255 | pf (' %8s %s', T.mute(dur), name); 256 | }); 257 | } 258 | }; 259 | 260 | // Printf helper 261 | function pf() { console.log(f.apply(this, arguments)); } 262 | -------------------------------------------------------------------------------- /lib/reporters/json.js: -------------------------------------------------------------------------------- 1 | var JsonReporter = module.exports = function(log, options) { 2 | this.log = log; 3 | }; 4 | 5 | JsonReporter.description = 'json exporter'; 6 | 7 | JsonReporter.prototype.summary = 8 | JsonReporter.prototype.range = 9 | JsonReporter.prototype.day = function(data) { 10 | console.log(JSON.stringify(data, null, 2)); 11 | }; 12 | -------------------------------------------------------------------------------- /lib/reporters/tmux.js: -------------------------------------------------------------------------------- 1 | var f = require('../formatter'); 2 | var symbols = require('./default').symbols; 3 | var duration = require('../helpers').duration; 4 | 5 | var TmuxReporter = module.exports = function(log, options) { 6 | this.log = log; 7 | }; 8 | 9 | TmuxReporter.description = 'tmux status bar'; 10 | 11 | var theme = TmuxReporter.theme = { 12 | sep: '#[fg=default] '+symbols.dot+' ', 13 | task: { 14 | name: '#[fg=7]', 15 | time: '#[fg=4]' 16 | }, 17 | 'break': { 18 | 'default': 'idle', 19 | name: '#[fg=default]', 20 | time: '#[fg=2]' 21 | } 22 | }; 23 | 24 | TmuxReporter.prototype.day = function(data) { 25 | if (!data.today) return; 26 | 27 | var now = data.last; 28 | var dur = duration(now.duration); 29 | 30 | if (dur.length === 0) dur = 'now'; 31 | 32 | if (now.type === 'task') 33 | console.log([ 34 | theme.task.name, 35 | now.project, 36 | theme.sep, 37 | theme.task.time, 38 | dur 39 | ].join('')); 40 | 41 | // Break (only if > 1m) 42 | else if (now.duration > 60000) 43 | console.log([ 44 | theme['break'].name, 45 | now.reason || theme['break']['default'], 46 | theme.sep, 47 | theme['break'].time, 48 | dur 49 | ].join('')); 50 | }; 51 | -------------------------------------------------------------------------------- /lib/spec_parser.js: -------------------------------------------------------------------------------- 1 | var re = {}; 2 | 3 | var t = /\d+(?::\d\d)?(?:[ap]m)?/.source; 4 | re.mins = /(?:minutes?|mins?|m)/ig; 5 | re.hours = /(?:hours?|hrs?|h)/ig; // eg: "hour", "hrs" 6 | re.time = new RegExp('^'+t+'\\s*|\\s*?'+t+'$', 'i'); // eg: "12:30pm" 7 | re.duration = new RegExp( 8 | "\\s*"+ 9 | "((?:\\d+ *(?:"+re.mins.source+"|"+re.hours.source+") ))"+ 10 | "(ago|from now)", 11 | 'i'); 12 | 13 | /** 14 | * Parses natural-language specs. 15 | * 16 | * parse("MyProject do mockups") 17 | * parse("MyProject do mockups 2m ago") 18 | * 19 | * Returns a JSON hash. 20 | */ 21 | 22 | var SpecParser = module.exports = function(str, options) { 23 | require('../vendor/sugar-date'); 24 | 25 | var date = new Date(); 26 | var m; 27 | 28 | // Handle "11:20pm" 29 | str = str.replace(re.time, function(time) { 30 | date = Date.create(date.format('{yyyy}-{MM}-{dd} '+time)); 31 | return ''; 32 | }); 33 | 34 | // Handle "3m ago" 35 | str = str.replace(re.duration, function(_, dur, direction) { 36 | dur = dur 37 | .replace(re.mins, ' minutes ') 38 | .replace(re.hours, ' hours '); 39 | 40 | date = Date.create(dur + direction); 41 | return ''; 42 | }); 43 | 44 | if (options && options.mode === 'break') { 45 | var reason = str.trim(); 46 | if (reason.length === 0) reason = null; 47 | 48 | return { 49 | type: 'break', 50 | reason: reason, 51 | date: date 52 | }; 53 | } 54 | else { 55 | // Split into first word + rest 56 | m = str.match(/^([^\s]+)(?:\s(.*))?$/); 57 | if (!m) return; 58 | 59 | return { 60 | type: 'task', 61 | project: m[1], 62 | task: m[2]||null, 63 | date: date 64 | }; 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /lib/summarize.js: -------------------------------------------------------------------------------- 1 | var keysort = require('./helpers').keysort; 2 | var _ = require('underscore'); 3 | 4 | module.exports = function(range) { 5 | var dates = range.dates; 6 | var projects = {}; 7 | 8 | dates.forEach(function(day) { 9 | day.entries.forEach(function(e) { 10 | if (e.type === 'task') { 11 | if (!projects[e.project]) 12 | projects[e.project] = { duration: 0, days: {} }; 13 | 14 | projects[e.project].duration += e.duration; 15 | projects[e.project].days[+day.date] = true; 16 | } 17 | }); 18 | }); 19 | 20 | // Summarize days 21 | _.each(projects, function(project) { 22 | project.days = _.size(project.days); 23 | }); 24 | 25 | projects = keysort(projects, function(p) { return -1 * p.duration; }); 26 | 27 | return { 28 | projects: projects 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /lib/time_log.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var _ = require('underscore'); 3 | var extend = require('util')._extend; 4 | var abs = require('./helpers').abs; 5 | var isToday = require('./helpers').isToday; 6 | var eachCons = require('./helpers').eachCons; 7 | var readFile = require('./helpers').readFile; 8 | var sortedPush = require('./helpers').sortedPush; 9 | 10 | /** 11 | * Time log model for a log. 12 | * 13 | * var log = new TimeLog(); 14 | * var log = new TimeLog('~/.timelog'); 15 | * 16 | * Adding data: 17 | * 18 | * log.push({ type: 'task', date: date, project: '...', task: '...' }); 19 | * log.push({ type: 'break', date: date }); 20 | * log.push({ type: 'break', date: date, reason: '...' }); 21 | * log.save(); 22 | * 23 | * Retrieving data: 24 | * 25 | * log.get(date) // get data for date 26 | * log.all() // all data 27 | * log.range([date,date]) // full data for day range 28 | * log.range() // get all 29 | * 30 | * log.dates() // list of dates 31 | * 32 | * Lower-level data retrieval: 33 | * 34 | * log.day() // entries for today 35 | * log.day(date) // entries for a `date` 36 | * 37 | * Properties: 38 | * 39 | * log.raw //=> {} (raw data) 40 | * log.file //=> "~/.timelogs" (filename to load/save) 41 | */ 42 | 43 | var TimeLog = module.exports = function(file) { 44 | this.raw = {}; 45 | this.file = file; 46 | 47 | if (file) this.load(file); 48 | }; 49 | 50 | /** 51 | * Date formats. 52 | */ 53 | 54 | TimeLog.prototype.formats = { 55 | date: '{yyyy}-{MM}-{dd} {dow}', 56 | time: '{h}:{mm}{tt}' 57 | }; 58 | 59 | /** 60 | * Fetches the dates. Returns an array of Date objects. 61 | * 62 | * log.dates() 63 | * //=> [ sept2, sept3, sept4, ... ] 64 | * 65 | * You can give it a range to find dates within that range: 66 | * 67 | * log.dates([ sept2, sept5 ]) 68 | * //=> [ sept2, sept3, sept5 ] 69 | * 70 | * Or even a single date: 71 | * 72 | * log.dates([ sept2, sept5 ]) 73 | * //=> [ sept2, sept3, sept5 ] 74 | */ 75 | 76 | TimeLog.prototype.dates = function(range) { 77 | var dates, log = this, date; 78 | 79 | dates = Object.keys(this.raw); 80 | dates = _.compact(_.map(dates, function(d) { return log.parseDate(d); })); 81 | 82 | if (!range) { /* All */ 83 | return dates; 84 | } 85 | else if (range && range[1]) { /* Range */ 86 | return _.filter(dates, function(d) { 87 | return d >= range[0] && d <= range[1]; 88 | }); 89 | } 90 | else if (date = log.parseDate(range)) { /* Single date */ 91 | return _.filter(dates, function(d) { 92 | return isToday(d, range); 93 | }); 94 | } 95 | }; 96 | 97 | /** 98 | * Formats a given `date` to a given format `fmt`. 99 | * 100 | * log.format(date, 'date') //=> "2013-03-04 mon" 101 | * log.format(date, 'time') //=> "3:14pm" 102 | */ 103 | 104 | TimeLog.prototype.format = function(date, fmt) { 105 | require('../vendor/sugar-date'); 106 | 107 | return (new Date(date)).format(this.formats[fmt]).toLowerCase(); 108 | }; 109 | 110 | /** 111 | * Converts a string `date` to a Date object. Returns a Date object, or null. 112 | * 113 | * log.parseDate("2013-03-04 mon") //=> [object Date] 114 | * log.parseDate("3:14pm", date) 115 | * log.parseDate("??") //=> null 116 | */ 117 | 118 | TimeLog.prototype.parseDate = function(date, context) { 119 | if (date && date.getHours) return date; 120 | 121 | // Catch `.parseDate('#', Date(september 2))` -- it will 122 | // otherwise return a date 123 | if (!date.match(/^\s*\d/)) return null; 124 | 125 | var DateParser = require('./date_parser'); 126 | return DateParser.date(date, context); 127 | }; 128 | 129 | /** 130 | * Adds a time log. 131 | * 132 | * log.push({ type: 'task', date: date, project: 'x', time: 'x' }); 133 | * log.push({ type: 'break', date: date }); 134 | * log.push({ type: 'task', date: date, reason: 'reason' }); 135 | */ 136 | 137 | TimeLog.prototype.push = function(spec) { 138 | var date = new Date(spec.date); 139 | 140 | var day = this.format(date, 'date'); 141 | var time = this.format(date, 'time'); 142 | var str = entryToString(spec); 143 | 144 | if (!this.raw[day]) this.raw[day] = {}; 145 | this.raw[day] = this._sortedPush(this.raw[day], time, str); 146 | 147 | return this; 148 | }; 149 | 150 | TimeLog.prototype._sortedPush = function(list, key, value) { 151 | var log = this; 152 | 153 | // Replace an existing entry? 154 | if (list[key]) { 155 | list[key] = value; 156 | return list; 157 | } else { 158 | var comp = function(key) { return +log.parseDate(key)||-1; }; 159 | return sortedPush(list, key, value, comp); 160 | } 161 | }; 162 | 163 | /** 164 | * Gets the data for the given `date`, which can be a Date, string (parseable 165 | * date), or number (Unix time). Returns an object. 166 | * 167 | * d = log.get(date); 168 | * d = log.get('2013-09-08'); 169 | * 170 | * Returns `null` when no data exists for that day, and throws an error if the 171 | * date is not parseable. 172 | * 173 | * The return value has these things: 174 | * 175 | * d.date - (Date) 176 | * d.entries - (Array of Entries) 177 | * d.last - (Entry) 178 | * d.summary - (Object) 179 | * d.summary.total - (Number, ms) duration of total time 180 | * d.summary.productive - (Number, ms) duration of productive time 181 | * d.summary.start - (Date) start of the day 182 | * d.summary.finish - (Date) end of the day 183 | * 184 | * Where `Entry` is: 185 | * 186 | * e = d.entries[0]; 187 | * e.type - (String) 'break' or 'task' 188 | * e.date - (Date) start date 189 | * e.endDate - (Date) end date 190 | * e.duration - (Number, ms) duration of time in milliseconds 191 | * e.project - (String) for tasks: the project name (or null) 192 | * e.task - (String) for tasks: task name (or null) 193 | * e.reason - (String) for breaks: the reason for the break (or null) 194 | * 195 | * If the current date being requested is today, you will also get some stuff: 196 | * 197 | * - `d.today` will be true 198 | * - `d.last.duration` will report how long you've been working on current task 199 | * - `d.summary` will take into account the currently-worked on task 200 | */ 201 | 202 | TimeLog.prototype.get = function(date) { 203 | date = this.parseDate(date); 204 | if (isNaN(+date)) throw new Error("Invalid Date"); 205 | 206 | var log = this; 207 | var dateStr = this.format(date, 'date'); 208 | var today = isToday(date); 209 | 210 | // Raw data 211 | var raw = this.raw[dateStr]; 212 | if (!raw) return null; 213 | 214 | // To items 215 | var list = _.compact(_.map(raw, function(item, itemDate) { 216 | itemDate = log.parseDate(itemDate, date); 217 | if (itemDate) 218 | return extend(log._parseItem(item), { date: itemDate }); 219 | })); 220 | 221 | // Fill in the missing endDate/duration from each item 222 | var entries = eachCons(list, function(a, b) { 223 | return extend(a, { 224 | endDate: b.date, 225 | duration: (b.date - a.date) 226 | }); 227 | }); 228 | 229 | var last = _.last(list) || null; 230 | var re = { 231 | date: date, 232 | entries: entries, 233 | last: last, 234 | summary: summarize(entries, last, today) 235 | }; 236 | 237 | if (today) { 238 | re.today = true; 239 | if (last) last.duration = new Date() - last.date; 240 | } 241 | 242 | return re; 243 | }; 244 | 245 | /** 246 | * Summarizes a day. Used in `get().summary`. 247 | * @private 248 | * 249 | * If it's the current day (`isToday`), add up the 'now working on' to totals. 250 | */ 251 | 252 | function summarize(entries, last, isToday) { 253 | var re = { 254 | productive: 0, 255 | total: 0, 256 | start: entries.length > 0 ? entries[0].date : null, 257 | finish: (entries.length > 0 && last) ? last.date : null 258 | }; 259 | 260 | entries.forEach(function(entry) { 261 | re.total += entry.duration; 262 | if (entry.type === 'task') 263 | re.productive += entry.duration; 264 | }); 265 | 266 | // If it's the current day, add up the 'now working on' to totals 267 | if (last && last.type === 'task' && isToday) { 268 | var dur = +new Date() - last.date; 269 | re.total += dur; 270 | re.productive += dur; 271 | } 272 | 273 | return re; 274 | } 275 | 276 | /** 277 | * Returns data for a range of dates. 278 | * 279 | * log.range([ date, date ]); 280 | */ 281 | 282 | TimeLog.prototype.range = function(range) { 283 | var log = this; 284 | 285 | var dates = this.dates(range); 286 | var re = { 287 | range: range, 288 | dates: [] 289 | }; 290 | 291 | dates.forEach(function(date) { 292 | re.dates.push(log.get(date)); 293 | }); 294 | 295 | return re; 296 | }; 297 | 298 | /** 299 | * Returns all data for all logged days. Same as `range()` without arguments. 300 | * 301 | * log.all(); 302 | */ 303 | 304 | TimeLog.prototype.all = function() { 305 | return this.range(); 306 | }; 307 | 308 | /** 309 | * Loads raw data from a file. 310 | * Called on constructor, so there's no need to call this manually. 311 | */ 312 | 313 | TimeLog.prototype.load = function(file) { 314 | var ini = require('ini'); 315 | var data = readFile(file) || ''; 316 | this.raw = ini.parse(data); 317 | 318 | return this; 319 | }; 320 | 321 | /** 322 | * Saves to the data file. 323 | * 324 | * Optionally, you may also pass a `file` to save it to another file (ie, 325 | * "save as"). 326 | */ 327 | 328 | TimeLog.prototype.save = function(file) { 329 | if (file) this.file = file; 330 | file = this.file; 331 | 332 | fs.writeFileSync(abs(file), this.toString()); 333 | return this; 334 | }; 335 | 336 | /** 337 | * Shows the time log as a string. 338 | * 339 | * log.toString(); 340 | * 341 | * Returns a string that's something like: 342 | * 343 | * [2013-09-02 mon] 344 | * 8:00am = Birthday call guests 345 | * 9:12am = Birthday get pizza ready 346 | * 9:39am = - play candy crush - 347 | */ 348 | 349 | TimeLog.prototype.toString = function() { 350 | var ini = require('ini'); 351 | return ini.stringify(this.raw); 352 | }; 353 | 354 | /** 355 | * Converts a raw item string into an item. (Hint: you can override this) 356 | * @private 357 | * 358 | * _parseItem("-- break") 359 | * _parseItem("MyProject: do mockups") 360 | */ 361 | 362 | TimeLog.prototype._parseItem = function(item) { 363 | var m; 364 | if (item.match(/^-*$/)) { 365 | return { 366 | type: 'break', 367 | reason: null 368 | }; 369 | } else if (m = item.match(/^-+ ?(.*?)$/)) { 370 | return { 371 | type: 'break', 372 | reason: m[1] 373 | }; 374 | } else if (m = item.match(/^(.+?) (.+)$/)) { 375 | return { 376 | type: 'task', 377 | project: m[1], 378 | task: m[2] 379 | }; 380 | } else { 381 | return { 382 | type: 'task', 383 | project: item 384 | }; 385 | } 386 | }; 387 | 388 | function entryToString(spec) { 389 | // Compose the string to be written. 390 | if (spec.type === 'break') { 391 | if (spec.reason && spec.reason.length > 0) { 392 | return '-- '+spec.reason; 393 | } else { 394 | return '-'; 395 | } 396 | } else { 397 | if (spec.task) { 398 | return spec.project + ' ' + spec.task; 399 | } else { 400 | return spec.project; 401 | } 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /man/timetip.1: -------------------------------------------------------------------------------- 1 | .\" generated with Ronn/v0.7.3 2 | .\" http://github.com/rtomayko/ronn/tree/0.7.3 3 | . 4 | .TH "TIMETIP" "1" "September 2013" "" "" 5 | . 6 | .SH "NAME" 7 | \fBtimetip\fR \- simple time tracker and time sheet manager 8 | . 9 | .SH "SYNOPSIS" 10 | \fBtimetip\fR [\fIshorthand\fR] 11 | . 12 | .br 13 | \fBtimetip\fR \fIcommand\fR [] 14 | . 15 | .SH "DESCRIPTION" 16 | Deliciously\-minimal time tracker for the command\-line, with a very simple command\-line interface\. Timetip is able to log your tasks, as well as query them and display it nicely\. 17 | . 18 | .SH "COMMANDS" 19 | . 20 | .TP 21 | \fBstart\fR \fInew\-task\-name\fR 22 | Start working on \fItask\fR\. See \fITASKS\fR\. 23 | . 24 | .TP 25 | \fBstop\fR [\fIreason\fR] 26 | Stops working\. you may optionally give a \fIreason\fR to be logged\. 27 | . 28 | .TP 29 | \fBshow\fR [\fIdate\fR|\fIrange\fR|all] 30 | Show entries for the given \fIdate\fR or \fIrange\fR, or show all entries\. When no arguments are given, \'today\' is assumed\. See \fIDATES\fR for details on the possible formats\. 31 | . 32 | .TP 33 | \fBedit\fR 34 | Opens the time log in your default text editor\. 35 | . 36 | .TP 37 | \fBdates\fR 38 | Lists down which dates have time entries in them\. 39 | . 40 | .TP 41 | \fBsummary\fR [\fIdate\fR|\fIrange\fR|all] 42 | Shows the summary for a given \fIdate\fR or \fIrange\fR\. When no arguments are given, \'all\' is assumed\. 43 | . 44 | .SH "SHORTHANDS" 45 | The shorthand syntax is a shortened form of the \fICOMMANDS\fR above to help you do the most common tasks\. 46 | . 47 | .TP 48 | \fBtimetip\fR \fInew\-task\-name\fR 49 | Starts working on the \fItask\fR you\'re doing\. (same as \fBstart\fR) 50 | . 51 | .TP 52 | \fBtimetip\fR \fIdate\fR|\fIrange\fR 53 | Shows entries for the given \fIdate\fR\. (\fBshow\fR) 54 | . 55 | .TP 56 | \fBtimetip\fR 57 | Shows entries for today\. (\fBshow\fR) 58 | . 59 | .TP 60 | \fBtimetip \-\fR [\fIreason\fR] 61 | Stops working, and logs your \fIreason\fR for the break\. (\fBstop\fR) 62 | . 63 | .SS "Examples" 64 | . 65 | .nf 66 | 67 | $ timetip Meeting <\-> timetip start Meeting 68 | $ timetip yesterday <\-> timetip show yesterday 69 | $ timetip \- lunch <\-> timetip stop lunch 70 | . 71 | .fi 72 | . 73 | .SH "OPTIONS" 74 | These are general options that work with almost all of the commands\. 75 | . 76 | .TP 77 | \fB\-R\fR, \fB\-\-reporter\fR \fIname\fR 78 | Use the reporter \fIname\fR\. See \fIREPORTERS\fR for more info\. 79 | . 80 | .TP 81 | \fB\-u\fR, \fB\-\-use\fR \fIpath\fR 82 | Load the JavaScript file in \fIpath\fR as a plugin\. This is useful for making your own custom reporters or commands\. 83 | . 84 | .TP 85 | \fB\-f\fR, \fB\-\-file\fR \fIpath\fR 86 | Use the file in \fIpath\fR as the time sheet file\. See \fIFILE FORMAT\fR\. 87 | . 88 | .TP 89 | \fB\-\-no\-color\fR 90 | Disables the use of color in outputs\. 91 | . 92 | .TP 93 | \fB\-h\fR, \fB\-\-help\fR 94 | Displays a help screen\. 95 | . 96 | .TP 97 | \fB\-V\fR, \fB\-\-version\fR 98 | Displays version information and exits\. 99 | . 100 | .SH "TASKS" 101 | Tasks are started with the \fBtimetip start\fR command\. The convention is that the first word is always the project name \-\- this is simply for the convenience of having summaries\. 102 | . 103 | .IP "" 4 104 | . 105 | .nf 106 | 107 | $ timetip start Meeting with Dan 108 | . 109 | .fi 110 | . 111 | .IP "" 0 112 | . 113 | .P 114 | The \fBstart\fR keyword is optional\. You may omit it and just type the task name out (as long as it doesn\'t clash with any of the internal commands)\. 115 | . 116 | .IP "" 4 117 | . 118 | .nf 119 | 120 | $ timetip Jsconf send out emails 121 | $ timetip Errands go to the grocery 122 | $ timetip Calls return the call of Amy\'s secretary 123 | . 124 | .fi 125 | . 126 | .IP "" 0 127 | . 128 | .SS "Times and offsets" 129 | You may specify a time as well\. This is useful for when you started working without logging it first\. 130 | . 131 | .IP "" 4 132 | . 133 | .nf 134 | 135 | $ timetip start Meeting 11:53am 136 | . 137 | .fi 138 | . 139 | .IP "" 0 140 | . 141 | .P 142 | You can also specify the time as an offset in the form of \fB ago\fR\. 143 | . 144 | .IP "" 4 145 | . 146 | .nf 147 | 148 | $ timetip start Meeting 15m ago 149 | $ timetip start Meeting 3 minutes ago 150 | . 151 | .fi 152 | . 153 | .IP "" 0 154 | . 155 | .P 156 | And of course, these conventions work with the \fISHORTHANDS\fR too: 157 | . 158 | .IP "" 4 159 | . 160 | .nf 161 | 162 | $ timetip Meeting 3m ago 163 | . 164 | .fi 165 | . 166 | .IP "" 0 167 | . 168 | .SS "Finishing tasks (and breaks)" 169 | When you\'re done with a task, simply terminate it with \fBstop\fR\. 170 | . 171 | .IP "" 4 172 | . 173 | .nf 174 | 175 | $ timetip stop 176 | . 177 | .fi 178 | . 179 | .IP "" 0 180 | . 181 | .P 182 | This creates a "break", and timetip will keep track of how long your break times are\. You can also specify a reason for your break\. 183 | . 184 | .IP "" 4 185 | . 186 | .nf 187 | 188 | $ timetip stop lunch break 189 | . 190 | .fi 191 | . 192 | .IP "" 0 193 | . 194 | .P 195 | You can also use \fB\-\fR (hyphen), which is an alias for \fBstop\fR\. 196 | . 197 | .IP "" 4 198 | . 199 | .nf 200 | 201 | $ timetip \- 202 | $ timetip \- coffee 203 | . 204 | .fi 205 | . 206 | .IP "" 0 207 | . 208 | .P 209 | Specifying times and offsets work just as well\. 210 | . 211 | .IP "" 4 212 | . 213 | .nf 214 | 215 | $ timetip stop 3m ago 216 | $ timetip stop phone call with dad 5m ago 217 | $ timetip stop 11:20pm 218 | . 219 | .fi 220 | . 221 | .IP "" 0 222 | . 223 | .SH "DATES" 224 | The \fIdate\fR strings are parsed as natural language dates\. They can be simple dates (eg: \fBMarch 5\fR), or any reasonable format that can be figured out unambiguously (eg: \fBlast thursday\fR)\. Some examples: 225 | . 226 | .IP "" 4 227 | . 228 | .nf 229 | 230 | $ timetip today 231 | $ timetip september 2 232 | $ timetip jan 20 233 | $ timetip yesterday 234 | $ timetip 1 month ago 235 | $ timetip 23 days ago 236 | . 237 | .fi 238 | . 239 | .IP "" 0 240 | . 241 | .P 242 | Ranges, often used for \fBshow\fR, and can be in the following formats: 243 | . 244 | .IP "\(bu" 4 245 | \fIdate\fR \- \fIdate\fR 246 | . 247 | .IP "\(bu" 4 248 | since \fIdate\fR 249 | . 250 | .IP "\(bu" 4 251 | all 252 | . 253 | .IP "" 0 254 | . 255 | .P 256 | Examples: 257 | . 258 | .IP "" 4 259 | . 260 | .nf 261 | 262 | $ timetip mar 2 \- mar 5 263 | $ timetip since last week 264 | $ timetip last mon \- last thu 265 | $ timetip all 266 | . 267 | .fi 268 | . 269 | .IP "" 0 270 | . 271 | .SH "FILE FORMAT" 272 | It\'s an ini file\. It is designed to be human\-editable and human\-readable, and you are encouraged to edit your time sheets outside of \fBtimetip\fR\. Files are saved to \fB~/\.timelogs\fR by default\. 273 | . 274 | .SS "Example" 275 | . 276 | .nf 277 | 278 | [2013\-09\-16 mon] 279 | 1:14pm = Misc write emails 280 | 2:42pm = Misc balance checkbook 281 | 3:00pm = 282 | 283 | [2013\-09\-18 wed] 284 | 3:14pm = Jsconf email speakers 285 | 3:59pm = \-\- coffee break 286 | 4:09pm = Jsconf check ticket sales 287 | 4:25pm = Errands grocery 288 | . 289 | .fi 290 | . 291 | .SS "Specifications" 292 | . 293 | .IP "\(bu" 4 294 | Dates are headings in the format of \fB[yyyy\-mm\-dd dom]\fR 295 | . 296 | .IP "\(bu" 4 297 | Tasks are in the format \fB