tomatoharvest 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +32 -0
- data/Rakefile +2 -0
- data/TODO +4 -0
- data/bin/toma +5 -0
- data/lib/tomatoharvest.rb +11 -0
- data/lib/tomatoharvest/cli.rb +46 -0
- data/lib/tomatoharvest/config.rb +21 -0
- data/lib/tomatoharvest/list.rb +76 -0
- data/lib/tomatoharvest/notifier.rb +15 -0
- data/lib/tomatoharvest/notifier/notification_center.rb +14 -0
- data/lib/tomatoharvest/os.rb +12 -0
- data/lib/tomatoharvest/pomodoro.rb +10 -0
- data/lib/tomatoharvest/task.rb +26 -0
- data/lib/tomatoharvest/time_entry.rb +110 -0
- data/lib/tomatoharvest/timer.rb +61 -0
- data/lib/tomatoharvest/tmux.rb +32 -0
- data/lib/tomatoharvest/version.rb +3 -0
- data/spec/helper.rb +81 -0
- data/spec/lib/tomatoharvest/cli_spec.rb +66 -0
- data/spec/lib/tomatoharvest/config_spec.rb +44 -0
- data/spec/lib/tomatoharvest/list_spec.rb +51 -0
- data/spec/lib/tomatoharvest/time_entry_spec.rb +106 -0
- data/spec/lib/tomatoharvest/timer_spec.rb +47 -0
- data/tomatoharvest.gemspec +30 -0
- metadata +191 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: f508e0466cc97b1f3b0d619bdfacbd2344b8cb8f
|
4
|
+
data.tar.gz: 3284680bde183e758259ec764f3ed84209931501
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 5544110fda87a0bb5b50d50f3a4e3d6e2da532088317ba286c6f4571950f33811d7ff03455a9ed2b2fc2668ca8d65a4eec134e70a39c0dd3e9f1a49e7295d418
|
7
|
+
data.tar.gz: 7786c551b13055b700232d439cfe52f5c890199579e5b60f7e6aac41bdb0732b73c2116e11a74d9d30f060e651dc04346d3f93bf20ac5ac0047d0d16293c9beb
|
data/.gitignore
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
.bundle
|
4
|
+
.config
|
5
|
+
.yardoc
|
6
|
+
Gemfile.lock
|
7
|
+
InstalledFiles
|
8
|
+
_yardoc
|
9
|
+
coverage
|
10
|
+
doc/
|
11
|
+
lib/bundler/man
|
12
|
+
pkg
|
13
|
+
rdoc
|
14
|
+
spec/reports
|
15
|
+
test/tmp
|
16
|
+
test/version_tmp
|
17
|
+
tmp
|
18
|
+
*.bundle
|
19
|
+
*.so
|
20
|
+
*.o
|
21
|
+
*.a
|
22
|
+
mkmf.log
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Sam Reh
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# TomatoHarvest
|
2
|
+
Command line pomodoro timer that logs to Harvest.
|
3
|
+
|
4
|
+
## Installation
|
5
|
+
|
6
|
+
$ gem install tomatoharvest
|
7
|
+
|
8
|
+
Create a file called ~/.tomaconfig with options:
|
9
|
+
```yaml
|
10
|
+
domain: myharvestdomain
|
11
|
+
username: username
|
12
|
+
password: password
|
13
|
+
project: harvest project
|
14
|
+
task: harvest task
|
15
|
+
```
|
16
|
+
|
17
|
+
## Usage
|
18
|
+
|
19
|
+
$ toma add "Some Task I Have To Do"
|
20
|
+
$ toma list
|
21
|
+
$ toma start 1
|
22
|
+
|
23
|
+
## Contributing
|
24
|
+
|
25
|
+
1. Fork it ( https://github.com/samuelreh/tomatoharvest/fork )
|
26
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
27
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
28
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
29
|
+
5. Create a new Pull Request
|
30
|
+
|
31
|
+
## Credits
|
32
|
+
This application is heavily inspired by https://github.com/visionmedia/pomo.
|
data/Rakefile
ADDED
data/bin/toma
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
require 'tomatoharvest/version'
|
2
|
+
require 'tomatoharvest/list'
|
3
|
+
require 'tomatoharvest/task'
|
4
|
+
require 'tomatoharvest/timer'
|
5
|
+
require 'tomatoharvest/cli'
|
6
|
+
require 'tomatoharvest/os'
|
7
|
+
require 'tomatoharvest/notifier'
|
8
|
+
require 'tomatoharvest/config'
|
9
|
+
require 'tomatoharvest/time_entry'
|
10
|
+
require 'tomatoharvest/pomodoro'
|
11
|
+
require 'tomatoharvest/tmux'
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'thor'
|
2
|
+
|
3
|
+
module TomatoHarvest
|
4
|
+
class CLI < ::Thor
|
5
|
+
DEFAULT_MINUTES = 25
|
6
|
+
|
7
|
+
desc "add", "add a task"
|
8
|
+
def add(name)
|
9
|
+
task = Task.new(name)
|
10
|
+
List.add(task)
|
11
|
+
say "#{task.name} added with id #{task.id}"
|
12
|
+
end
|
13
|
+
|
14
|
+
desc "list", "list all tasks"
|
15
|
+
def list
|
16
|
+
list = List.all.map do |task|
|
17
|
+
[task.id, task.name]
|
18
|
+
end
|
19
|
+
list.unshift(['id', 'name'])
|
20
|
+
|
21
|
+
shell = Thor::Base.shell.new
|
22
|
+
shell.print_table(list)
|
23
|
+
end
|
24
|
+
|
25
|
+
desc "start", "start a task"
|
26
|
+
def start(id, minutes = DEFAULT_MINUTES)
|
27
|
+
task = List.find(id)
|
28
|
+
config = Config.load.merge("name" => task.name)
|
29
|
+
entry = TimeEntry.build_and_test(config)
|
30
|
+
|
31
|
+
say "Timer started for #{task.name}"
|
32
|
+
Timer.start(task.id, minutes: minutes, time_entry: entry)
|
33
|
+
end
|
34
|
+
|
35
|
+
desc "stop", "stop current timer"
|
36
|
+
def stop
|
37
|
+
if Timer.stop
|
38
|
+
say "Timer stopped"
|
39
|
+
else
|
40
|
+
say "Timer not running"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module TomatoHarvest
|
2
|
+
class Config
|
3
|
+
CONFIG_PATH = File.expand_path("#{ENV['$HOME']}/.tomaconfig")
|
4
|
+
LOCAL_CONFIG_PATH = File.join(Dir.pwd, '.tomaconfig')
|
5
|
+
|
6
|
+
def self.load(options = {})
|
7
|
+
if !(File.exists? CONFIG_PATH)
|
8
|
+
File.open(CONFIG_PATH, 'w') do |file|
|
9
|
+
YAML.dump({}, file)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
hash = YAML.load_file(CONFIG_PATH)
|
14
|
+
if File.exists? LOCAL_CONFIG_PATH
|
15
|
+
hash.merge!(YAML.load_file(LOCAL_CONFIG_PATH))
|
16
|
+
end
|
17
|
+
|
18
|
+
hash
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module TomatoHarvest
|
4
|
+
class List
|
5
|
+
PATH = File.expand_path("#{ENV['$HOME']}/.toma")
|
6
|
+
|
7
|
+
attr_reader :items
|
8
|
+
|
9
|
+
alias :all :items
|
10
|
+
|
11
|
+
def self.add(item)
|
12
|
+
new.tap do |list|
|
13
|
+
list.add(item)
|
14
|
+
list.save
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.all
|
19
|
+
new.all
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.find(id)
|
23
|
+
new.find(id)
|
24
|
+
end
|
25
|
+
|
26
|
+
def initialize
|
27
|
+
if File.exists?(PATH)
|
28
|
+
@items = load_list
|
29
|
+
else
|
30
|
+
@items = []
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def find(id)
|
35
|
+
# TODO speed this up with an algo
|
36
|
+
all.find do |item|
|
37
|
+
item.id == id.to_i
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def save
|
42
|
+
yaml = YAML::dump(@items)
|
43
|
+
File.open(PATH, "w+") do |f|
|
44
|
+
f.write(yaml)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def add(item)
|
49
|
+
if last_item = @items.last
|
50
|
+
id = last_item.id
|
51
|
+
else
|
52
|
+
id = 0
|
53
|
+
end
|
54
|
+
item.id = id + 1
|
55
|
+
|
56
|
+
@items << item
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def load_list
|
62
|
+
string = ""
|
63
|
+
|
64
|
+
# better way to do this?
|
65
|
+
File.open(PATH, "r") do |f|
|
66
|
+
while line = f.gets
|
67
|
+
string += line
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
YAML::load(string)
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require_relative 'notifier/notification_center'
|
2
|
+
|
3
|
+
module TomatoHarvest
|
4
|
+
class Notifier
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@notifier = TomatoHarvest::Notifier::NotificationCenter.new
|
8
|
+
end
|
9
|
+
|
10
|
+
def notify(message, opts = {})
|
11
|
+
@notifier.notify(message, opts)
|
12
|
+
end
|
13
|
+
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'terminal-notifier' if TomatoHarvest::OS.mac?
|
2
|
+
|
3
|
+
module TomatoHarvest
|
4
|
+
class Notifier
|
5
|
+
class NotificationCenter
|
6
|
+
def notify(message, opts = {})
|
7
|
+
title = 'TomatoHarvest'
|
8
|
+
|
9
|
+
TerminalNotifier.notify message, :title => title, :subtitle => opts[:subtitle]
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module TomatoHarvest
|
2
|
+
class Task
|
3
|
+
|
4
|
+
attr_reader :name
|
5
|
+
attr_accessor :id, :pomodoros
|
6
|
+
|
7
|
+
def initialize(name)
|
8
|
+
@name = name
|
9
|
+
@pomodoros = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def log_pomodoro(seconds)
|
13
|
+
finished_at = DateTime.now
|
14
|
+
self.pomodoros << Pomodoro.new(seconds, finished_at)
|
15
|
+
end
|
16
|
+
|
17
|
+
def logged_minutes
|
18
|
+
sum = pomodoros.inject(0) do |sum, pomodoro|
|
19
|
+
sum + pomodoro.seconds
|
20
|
+
end
|
21
|
+
|
22
|
+
sum / 60.0
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'set'
|
2
|
+
require 'harvested'
|
3
|
+
|
4
|
+
module TomatoHarvest
|
5
|
+
class TimeEntry
|
6
|
+
|
7
|
+
attr_accessor :options
|
8
|
+
|
9
|
+
def self.build_and_test(options = {})
|
10
|
+
required = ["domain", "username", "password", "project", "task", "name"].to_set
|
11
|
+
keys = options.keys.to_set
|
12
|
+
if required.subset?(keys)
|
13
|
+
new(options).tap do |entry|
|
14
|
+
entry.test
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(options = {})
|
20
|
+
self.options = options
|
21
|
+
end
|
22
|
+
|
23
|
+
##
|
24
|
+
# Check that the project and task were found on Harvest
|
25
|
+
|
26
|
+
def test
|
27
|
+
raise "Couldn't find project" unless project
|
28
|
+
raise "Couldn't find task type" unless task
|
29
|
+
end
|
30
|
+
|
31
|
+
##
|
32
|
+
# Persist time entry to Harvest tracker
|
33
|
+
|
34
|
+
def log(seconds)
|
35
|
+
hours = seconds_to_hours(seconds)
|
36
|
+
options = {
|
37
|
+
notes: notes,
|
38
|
+
hours: hours,
|
39
|
+
spent_at: date,
|
40
|
+
project_id: project.id,
|
41
|
+
task_id: task.id
|
42
|
+
}
|
43
|
+
|
44
|
+
|
45
|
+
existing_entry = time_api.all.find do |entry|
|
46
|
+
entry.notes == self.notes
|
47
|
+
end
|
48
|
+
|
49
|
+
if existing_entry
|
50
|
+
existing_entry.hours += hours
|
51
|
+
existing_entry.spent_at = date
|
52
|
+
time_api.update(existing_entry)
|
53
|
+
else
|
54
|
+
entry = Harvest::TimeEntry.new(options)
|
55
|
+
time_api.create(entry)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
##
|
60
|
+
# Notes to send to tracker
|
61
|
+
|
62
|
+
def notes
|
63
|
+
options["name"]
|
64
|
+
end
|
65
|
+
|
66
|
+
##
|
67
|
+
# Date for task (today), formatted properly for tracker
|
68
|
+
|
69
|
+
def date
|
70
|
+
Date.today
|
71
|
+
end
|
72
|
+
|
73
|
+
##
|
74
|
+
# Convert seconds into hours
|
75
|
+
|
76
|
+
def seconds_to_hours(seconds)
|
77
|
+
minutes = (seconds / 60.0)
|
78
|
+
unrounded = (minutes / 60.0)
|
79
|
+
(unrounded * 100).ceil / 100.0
|
80
|
+
end
|
81
|
+
|
82
|
+
##
|
83
|
+
# Harvest project with name matching options[:project]
|
84
|
+
|
85
|
+
def project
|
86
|
+
time_api.trackable_projects.find do |project|
|
87
|
+
project.name == options["project"]
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
##
|
92
|
+
# Harvest task with name matching options[:task]
|
93
|
+
|
94
|
+
def task
|
95
|
+
project.tasks.find do |task|
|
96
|
+
task.name == options["task"]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def time_api
|
103
|
+
@time_api ||= begin
|
104
|
+
client = Harvest.client(options["domain"], options["username"], options["password"])
|
105
|
+
Harvest::API::Time.new(client.credentials)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'daemons'
|
2
|
+
|
3
|
+
module TomatoHarvest
|
4
|
+
class Timer
|
5
|
+
PID_DIR = '~'
|
6
|
+
PID_NAME = '.toma'
|
7
|
+
|
8
|
+
def self.start(task_id, options = {})
|
9
|
+
new(task_id, options).start
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.stop
|
13
|
+
if monitor = Daemons::Monitor.find(File.expand_path(DIR), APP_NAME)
|
14
|
+
monitor.stop
|
15
|
+
true
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(task_id, options = {})
|
20
|
+
@minutes = options[:minutes]
|
21
|
+
@time_entry = options[:time_entry]
|
22
|
+
@notifier = Notifier.new
|
23
|
+
@list = List.new
|
24
|
+
@task = @list.find(task_id)
|
25
|
+
@timer = 0
|
26
|
+
@tmux = Tmux.new
|
27
|
+
end
|
28
|
+
|
29
|
+
def start
|
30
|
+
if Daemons.daemonize(app_name: PID_NAME, dir: PID_DIR, dir_mode: :normal)
|
31
|
+
at_exit { save_and_log }
|
32
|
+
run_timer
|
33
|
+
else
|
34
|
+
run_timer
|
35
|
+
save_and_log
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def run_timer
|
42
|
+
@notifier.notify "Pomodoro started for #{@minutes} minutes", :subtitle => @task.name
|
43
|
+
|
44
|
+
(@minutes * 60).times do |i|
|
45
|
+
sleep 1
|
46
|
+
@timer += 1
|
47
|
+
@tmux.update(@timer)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def save_and_log
|
52
|
+
@task.log_pomodoro(@timer)
|
53
|
+
@list.save
|
54
|
+
@time_entry.log(@timer) if @time_entry
|
55
|
+
@notifier.notify "Pomodoro finished", :subtitle => "Pomodoro finished!"
|
56
|
+
@tmux.update(0)
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module TomatoHarvest
|
2
|
+
class Tmux
|
3
|
+
|
4
|
+
def update(time)
|
5
|
+
write_tmux_time time
|
6
|
+
refresh_tmux_status_bar
|
7
|
+
end
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def tmux_time(time)
|
12
|
+
mm, ss = time.divmod(60)
|
13
|
+
ss = ss.to_s.rjust(2, "0")
|
14
|
+
"#[default]#[fg=green]#{mm}:#{ss}#[default]"
|
15
|
+
end
|
16
|
+
|
17
|
+
def write_tmux_time(time)
|
18
|
+
path = File.join(ENV['HOME'],'.tomatmux')
|
19
|
+
File.open(path, 'w') do |file|
|
20
|
+
file.write tmux_time(time)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def refresh_tmux_status_bar
|
25
|
+
pid = Process.fork do
|
26
|
+
exec "tmux refresh-client -S -t $(tmux list-clients -F '\#{client_tty}')"
|
27
|
+
end
|
28
|
+
Process.detach(pid)
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
data/spec/helper.rb
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
|
2
|
+
require 'thor'
|
3
|
+
require 'tomatoharvest'
|
4
|
+
|
5
|
+
require 'webmock/rspec'
|
6
|
+
require 'minitest/unit'
|
7
|
+
|
8
|
+
WebMock.disable_net_connect!(allow_localhost: true)
|
9
|
+
|
10
|
+
RSpec.configure do |c|
|
11
|
+
c.include MiniTest::Assertions
|
12
|
+
|
13
|
+
#
|
14
|
+
# Speed up the timer
|
15
|
+
#
|
16
|
+
c.before :all do
|
17
|
+
class TomatoHarvest::Timer
|
18
|
+
def sleep(time)
|
19
|
+
super(time/100000)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
#
|
25
|
+
# Stub HTTP requests
|
26
|
+
#
|
27
|
+
c.before do
|
28
|
+
body = {
|
29
|
+
projects: [ {
|
30
|
+
name: 'Pomdoro',
|
31
|
+
id: 1,
|
32
|
+
tasks: [
|
33
|
+
{
|
34
|
+
name: 'Ruby Development',
|
35
|
+
id: 1
|
36
|
+
}
|
37
|
+
]
|
38
|
+
} ],
|
39
|
+
day_entries: []
|
40
|
+
}
|
41
|
+
|
42
|
+
stub_request(:get, /https:\/\/user:password@domain.harvestapp.com\/daily\/.*/).
|
43
|
+
with(:headers => {'Accept'=>'application/json', 'Content-Type'=>'application/json; charset=utf-8', 'User-Agent'=>'Harvestable/2.0.0'}).
|
44
|
+
to_return(:status => 200, :body => body.to_json, :headers => {})
|
45
|
+
|
46
|
+
stub_request(:post, "https://user:password@domain.harvestapp.com/daily/add").
|
47
|
+
with(:headers => {'Accept'=>'application/json', 'Content-Type'=>'application/json; charset=utf-8', 'User-Agent'=>'Harvestable/2.0.0'}).
|
48
|
+
to_return(:status => 200, :body => "", :headers => {})
|
49
|
+
end
|
50
|
+
|
51
|
+
#
|
52
|
+
# Don't daemonize for tests
|
53
|
+
# Dont notify the terminal
|
54
|
+
#
|
55
|
+
c.before do
|
56
|
+
Daemons.stub(daemonize: false)
|
57
|
+
TerminalNotifier.stub(notify: true)
|
58
|
+
TomatoHarvest::Tmux.any_instance.stub(update: true)
|
59
|
+
end
|
60
|
+
|
61
|
+
#
|
62
|
+
# Cleanup .toma and .tomaconfig
|
63
|
+
#
|
64
|
+
|
65
|
+
[
|
66
|
+
["TomatoHarvest::Config::CONFIG_PATH", File.expand_path('spec/.tomaconfig')],
|
67
|
+
["TomatoHarvest::Config::LOCAL_CONFIG_PATH", File.expand_path('.tomaconfig')],
|
68
|
+
["TomatoHarvest::List::PATH", File.expand_path('spec/.toma')]
|
69
|
+
].each do |tuple|
|
70
|
+
path = tuple[1]
|
71
|
+
|
72
|
+
c.before :each do
|
73
|
+
stub_const(tuple[0], path)
|
74
|
+
File.delete(path) if File.exists?(path)
|
75
|
+
end
|
76
|
+
|
77
|
+
c.after :each do
|
78
|
+
File.delete(path) if File.exists?(path)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe TomatoHarvest::CLI do
|
4
|
+
|
5
|
+
describe 'add' do
|
6
|
+
|
7
|
+
it 'adds a task to the list' do
|
8
|
+
out = capture_io { TomatoHarvest::CLI.start ['add', 'foo'] }.join ''
|
9
|
+
expect(out).to match(/foo added with id 1/)
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
13
|
+
|
14
|
+
describe 'list' do
|
15
|
+
|
16
|
+
it 'shows a table of the tasks' do
|
17
|
+
TomatoHarvest::CLI.start ['add', 'foo']
|
18
|
+
TomatoHarvest::CLI.start ['add', 'bar']
|
19
|
+
out = capture_io { TomatoHarvest::CLI.start ['list'] }.join ''
|
20
|
+
expect(out).to match(/id name\n\s*1 foo\n\s*2 bar/)
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
describe 'start' do
|
26
|
+
before do
|
27
|
+
TomatoHarvest::CLI.start ['add', 'foo']
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'starts the timer' do
|
31
|
+
out = capture_io { TomatoHarvest::CLI.start ['start', 1] }.join ''
|
32
|
+
expect(out).to match(/Timer started for foo/)
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'starts the timer with specified length' do
|
36
|
+
out = capture_io { TomatoHarvest::CLI.start ['start', 1, 15] }.join ''
|
37
|
+
expect(out).to match(/Timer started for foo/)
|
38
|
+
end
|
39
|
+
|
40
|
+
context 'when config has valid harvest options' do
|
41
|
+
|
42
|
+
before do
|
43
|
+
options = {
|
44
|
+
project: 'Pomodoro',
|
45
|
+
type: 'Ruby Development',
|
46
|
+
domain: 'domain',
|
47
|
+
username: 'user',
|
48
|
+
password: 'password'
|
49
|
+
}
|
50
|
+
|
51
|
+
path = TomatoHarvest::Config::CONFIG_PATH
|
52
|
+
File.open(path, 'w') do |file|
|
53
|
+
YAML::dump(options, file)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'starts the timer with specified length' do
|
58
|
+
out = capture_io { TomatoHarvest::CLI.start ['start', 1] }.join ''
|
59
|
+
expect(out).to match(/Timer started for foo/)
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe TomatoHarvest::Config do
|
4
|
+
|
5
|
+
describe '.load' do
|
6
|
+
let(:global_options) do
|
7
|
+
{
|
8
|
+
project: 'Project',
|
9
|
+
type: 'Ruby Development',
|
10
|
+
}
|
11
|
+
end
|
12
|
+
|
13
|
+
before do
|
14
|
+
File.open(TomatoHarvest::Config::CONFIG_PATH, 'w') do |file|
|
15
|
+
YAML::dump(global_options, file)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'loads from the yaml config file' do
|
20
|
+
expect(TomatoHarvest::Config.load).to eql(global_options)
|
21
|
+
end
|
22
|
+
|
23
|
+
context 'when there is a config file in the current dir' do
|
24
|
+
|
25
|
+
it 'overrides global config' do
|
26
|
+
options = {
|
27
|
+
type: 'JS Development',
|
28
|
+
}
|
29
|
+
local_config = File.join(Dir.pwd, '.tomaconfig')
|
30
|
+
|
31
|
+
File.open(local_config, 'w') do |file|
|
32
|
+
YAML::dump(options, file)
|
33
|
+
end
|
34
|
+
|
35
|
+
result = global_options.merge(options)
|
36
|
+
|
37
|
+
expect(TomatoHarvest::Config.load).to eql(result)
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe TomatoHarvest::List do
|
4
|
+
|
5
|
+
def add_task(name)
|
6
|
+
task = TomatoHarvest::Task.new(name)
|
7
|
+
TomatoHarvest::List.add(task)
|
8
|
+
end
|
9
|
+
|
10
|
+
describe '.add' do
|
11
|
+
|
12
|
+
it 'adds to the list' do
|
13
|
+
add_task('foo')
|
14
|
+
expect(described_class.all.first).to be_an_instance_of(TomatoHarvest::Task)
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '.list' do
|
20
|
+
|
21
|
+
it 'should have two items' do
|
22
|
+
add_task('foo')
|
23
|
+
add_task('bar')
|
24
|
+
expect(described_class.all.count).to eql(2)
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
describe '.find' do
|
30
|
+
|
31
|
+
it 'returns the task with the corresponding id' do
|
32
|
+
add_task('foo')
|
33
|
+
add_task('bar')
|
34
|
+
expect(described_class.find(1).name).to eql('foo')
|
35
|
+
expect(described_class.find(2).name).to eql('bar')
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
|
40
|
+
describe '#add' do
|
41
|
+
|
42
|
+
it 'adds the task to the items array' do
|
43
|
+
task = TomatoHarvest::Task.new('foo')
|
44
|
+
list = described_class.new
|
45
|
+
list.add(task)
|
46
|
+
expect(list.items.first.id).to eql(1)
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe TomatoHarvest::TimeEntry do
|
4
|
+
|
5
|
+
describe '#test' do
|
6
|
+
|
7
|
+
let(:entry) do
|
8
|
+
described_class.new.tap do |entry|
|
9
|
+
entry.stub(project: double, task: double)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
it "raises an error if project can't be found" do
|
14
|
+
entry.stub(project: nil)
|
15
|
+
expect{ entry.test }.to raise_error("Couldn't find project")
|
16
|
+
end
|
17
|
+
|
18
|
+
it "raises an error if task can't be found" do
|
19
|
+
entry.stub(task: nil)
|
20
|
+
expect{ entry.test }.to raise_error("Couldn't find task type")
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
describe '#log' do
|
26
|
+
|
27
|
+
context 'task is already logged today' do
|
28
|
+
let(:options) do
|
29
|
+
{
|
30
|
+
'domain' => 'domain',
|
31
|
+
'username' => 'user',
|
32
|
+
'password' => 'password',
|
33
|
+
'project' => 'Pomodoro',
|
34
|
+
'task' => 'Ruby Development',
|
35
|
+
'name' => 'Template Refactoring'
|
36
|
+
}
|
37
|
+
end
|
38
|
+
|
39
|
+
before do
|
40
|
+
body = {
|
41
|
+
projects: [ {
|
42
|
+
name: 'Pomodoro',
|
43
|
+
id: 1,
|
44
|
+
tasks: [
|
45
|
+
{
|
46
|
+
name: 'Ruby Development',
|
47
|
+
id: 1
|
48
|
+
}
|
49
|
+
]
|
50
|
+
} ],
|
51
|
+
|
52
|
+
day_entries: [ {
|
53
|
+
notes: 'Template Refactoring',
|
54
|
+
project_id: 1,
|
55
|
+
task_id: 1,
|
56
|
+
hours: 1
|
57
|
+
} ]
|
58
|
+
}
|
59
|
+
|
60
|
+
stub_request(:get, /https:\/\/user:password@domain.harvestapp.com\/daily\/.*/).
|
61
|
+
to_return(:status => 200, :body => body.to_json, :headers => {})
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'updates exisiting entry' do
|
65
|
+
update_url = "https://user:password@domain.harvestapp.com/daily/update/"
|
66
|
+
body = {
|
67
|
+
notes: "Template Refactoring",
|
68
|
+
project_id: 1,
|
69
|
+
task_id: 1,
|
70
|
+
hours: 1.5,
|
71
|
+
spent_at: Date.today.strftime("%Y-%m-%d")
|
72
|
+
}
|
73
|
+
stub = stub_request(:put, update_url).with(:body => body.to_json)
|
74
|
+
|
75
|
+
entry = TomatoHarvest::TimeEntry.new(options)
|
76
|
+
entry.log(60 * 30)
|
77
|
+
|
78
|
+
stub.should have_been_requested
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
84
|
+
|
85
|
+
describe '#notes' do
|
86
|
+
|
87
|
+
it 'concats name and description' do
|
88
|
+
entry = described_class.new('name' => "Name")
|
89
|
+
expect(entry.notes).to eql("Name")
|
90
|
+
end
|
91
|
+
|
92
|
+
end
|
93
|
+
|
94
|
+
describe '#hours' do
|
95
|
+
|
96
|
+
it 'converts minutes to hours' do
|
97
|
+
expect(described_class.new.seconds_to_hours(60 * 60)).to be(1.00)
|
98
|
+
end
|
99
|
+
|
100
|
+
it 'round to hundreths' do
|
101
|
+
expect(described_class.new.seconds_to_hours(61 * 60)).to be(1.02)
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
105
|
+
|
106
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
describe TomatoHarvest::Timer do
|
4
|
+
|
5
|
+
describe '.start' do
|
6
|
+
|
7
|
+
let(:task) { TomatoHarvest::Task.new('foo') }
|
8
|
+
|
9
|
+
before do
|
10
|
+
list = TomatoHarvest::List.new
|
11
|
+
list.add(task)
|
12
|
+
list.save
|
13
|
+
end
|
14
|
+
|
15
|
+
def stub_notifier(minutes)
|
16
|
+
message = "Pomodoro started for #{minutes} minutes"
|
17
|
+
options = {:title=>"TomatoHarvest", :subtitle=> 'foo'}
|
18
|
+
TerminalNotifier.should_receive(:notify).with(message, options)
|
19
|
+
|
20
|
+
message = "Pomodoro finished"
|
21
|
+
options = {:title=>"TomatoHarvest", :subtitle=> 'Pomodoro finished!'}
|
22
|
+
TerminalNotifier.should_receive(:notify).with(message, options)
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'can run for a custom length' do
|
26
|
+
TomatoHarvest::Timer.start(task.id, minutes: 15)
|
27
|
+
|
28
|
+
reloaded_task = TomatoHarvest::List.find(task.id)
|
29
|
+
expect(reloaded_task.logged_minutes).to eql(15.0)
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'can be run twice' do
|
33
|
+
TomatoHarvest::Timer.start(task.id, minutes: 20)
|
34
|
+
TomatoHarvest::Timer.start(task.id, minutes: 20)
|
35
|
+
reloaded_task = TomatoHarvest::List.find(task.id)
|
36
|
+
expect(reloaded_task.logged_minutes).to eql(40.0)
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'logs a time entry if passed in' do
|
40
|
+
entry = double
|
41
|
+
entry.should_receive(:log)
|
42
|
+
TomatoHarvest::Timer.start(task.id, time_entry: entry, minutes: 25)
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'tomatoharvest'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "tomatoharvest"
|
8
|
+
spec.version = TomatoHarvest::VERSION
|
9
|
+
spec.authors = ["Sam Reh"]
|
10
|
+
spec.email = ["samuelreh@gmail.com"]
|
11
|
+
spec.summary = %q{Log your pomodoros to Harvest}
|
12
|
+
spec.description = %q{Command line pomodoro timer that logs to Harvest.}
|
13
|
+
spec.homepage = "http://github.com/samuelreh/tomatoharvest/"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_development_dependency "bundler", "~> 1.6"
|
22
|
+
spec.add_development_dependency "rake"
|
23
|
+
spec.add_development_dependency "rspec"
|
24
|
+
spec.add_development_dependency "webmock"
|
25
|
+
|
26
|
+
spec.add_dependency('thor', '~> 0.19')
|
27
|
+
spec.add_dependency('harvested')
|
28
|
+
spec.add_dependency('daemons')
|
29
|
+
spec.add_dependency('terminal-notifier', '~> 1.4') if TomatoHarvest::OS.mac?
|
30
|
+
end
|
metadata
ADDED
@@ -0,0 +1,191 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: tomatoharvest
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Sam Reh
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-05-10 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ~>
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.6'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ~>
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.6'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '>='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '>='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - '>='
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: webmock
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - '>='
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: thor
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ~>
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0.19'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ~>
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0.19'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: harvested
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - '>='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :runtime
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - '>='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: daemons
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - '>='
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :runtime
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - '>='
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: terminal-notifier
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ~>
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '1.4'
|
118
|
+
type: :runtime
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ~>
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '1.4'
|
125
|
+
description: Command line pomodoro timer that logs to Harvest.
|
126
|
+
email:
|
127
|
+
- samuelreh@gmail.com
|
128
|
+
executables:
|
129
|
+
- toma
|
130
|
+
extensions: []
|
131
|
+
extra_rdoc_files: []
|
132
|
+
files:
|
133
|
+
- .gitignore
|
134
|
+
- Gemfile
|
135
|
+
- LICENSE.txt
|
136
|
+
- README.md
|
137
|
+
- Rakefile
|
138
|
+
- TODO
|
139
|
+
- bin/toma
|
140
|
+
- lib/tomatoharvest.rb
|
141
|
+
- lib/tomatoharvest/cli.rb
|
142
|
+
- lib/tomatoharvest/config.rb
|
143
|
+
- lib/tomatoharvest/list.rb
|
144
|
+
- lib/tomatoharvest/notifier.rb
|
145
|
+
- lib/tomatoharvest/notifier/notification_center.rb
|
146
|
+
- lib/tomatoharvest/os.rb
|
147
|
+
- lib/tomatoharvest/pomodoro.rb
|
148
|
+
- lib/tomatoharvest/task.rb
|
149
|
+
- lib/tomatoharvest/time_entry.rb
|
150
|
+
- lib/tomatoharvest/timer.rb
|
151
|
+
- lib/tomatoharvest/tmux.rb
|
152
|
+
- lib/tomatoharvest/version.rb
|
153
|
+
- spec/helper.rb
|
154
|
+
- spec/lib/tomatoharvest/cli_spec.rb
|
155
|
+
- spec/lib/tomatoharvest/config_spec.rb
|
156
|
+
- spec/lib/tomatoharvest/list_spec.rb
|
157
|
+
- spec/lib/tomatoharvest/time_entry_spec.rb
|
158
|
+
- spec/lib/tomatoharvest/timer_spec.rb
|
159
|
+
- tomatoharvest.gemspec
|
160
|
+
homepage: http://github.com/samuelreh/tomatoharvest/
|
161
|
+
licenses:
|
162
|
+
- MIT
|
163
|
+
metadata: {}
|
164
|
+
post_install_message:
|
165
|
+
rdoc_options: []
|
166
|
+
require_paths:
|
167
|
+
- lib
|
168
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
169
|
+
requirements:
|
170
|
+
- - '>='
|
171
|
+
- !ruby/object:Gem::Version
|
172
|
+
version: '0'
|
173
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
174
|
+
requirements:
|
175
|
+
- - '>='
|
176
|
+
- !ruby/object:Gem::Version
|
177
|
+
version: '0'
|
178
|
+
requirements: []
|
179
|
+
rubyforge_project:
|
180
|
+
rubygems_version: 2.0.3
|
181
|
+
signing_key:
|
182
|
+
specification_version: 4
|
183
|
+
summary: Log your pomodoros to Harvest
|
184
|
+
test_files:
|
185
|
+
- spec/helper.rb
|
186
|
+
- spec/lib/tomatoharvest/cli_spec.rb
|
187
|
+
- spec/lib/tomatoharvest/config_spec.rb
|
188
|
+
- spec/lib/tomatoharvest/list_spec.rb
|
189
|
+
- spec/lib/tomatoharvest/time_entry_spec.rb
|
190
|
+
- spec/lib/tomatoharvest/timer_spec.rb
|
191
|
+
has_rdoc:
|