punched 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -0
- data/.gitignore +2 -0
- data/.travis.yml +11 -0
- data/Gemfile +2 -0
- data/LICENSE +674 -0
- data/README.md +138 -0
- data/bin/punched +61 -0
- data/lib/punchcard.rb +316 -0
- data/punched.gemspec +19 -0
- data/spec/punchcard_spec.rb +156 -0
- metadata +54 -0
data/README.md
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
# Punchcard
|
2
|
+
## Minimal time tracking tool for cli
|
3
|
+
|
4
|
+
[![Build Status](https://travis-ci.org/pstaender/punchcard.svg?branch=master)](https://travis-ci.org/pstaender/punchcard)
|
5
|
+
|
6
|
+
### Requirements
|
7
|
+
|
8
|
+
* ruby 2+
|
9
|
+
* unix bash (maybe windows works as well?!?)
|
10
|
+
|
11
|
+
### Install
|
12
|
+
|
13
|
+
```sh
|
14
|
+
$ gem install punched
|
15
|
+
```
|
16
|
+
|
17
|
+
Or choose your preferred filename and location:
|
18
|
+
|
19
|
+
```sh
|
20
|
+
$ echo '#!/usr/bin/env ruby' > /usr/local/bin/punched
|
21
|
+
$ curl https://raw.githubusercontent.com/pstaender/punchcard/master/lib/punchcard.rb >> /usr/local/bin/punched
|
22
|
+
$ curl https://raw.githubusercontent.com/pstaender/punchcard/master/bin/punched >> /usr/local/bin/punched
|
23
|
+
$ chmod +x /usr/local/bin/punched
|
24
|
+
```
|
25
|
+
|
26
|
+
### Usage
|
27
|
+
|
28
|
+
#### Start Project
|
29
|
+
|
30
|
+
```sh
|
31
|
+
$ punched start "Punchcard (programming)"
|
32
|
+
```
|
33
|
+
|
34
|
+
#### Wildcard
|
35
|
+
|
36
|
+
Save keystrokes by using wildcard. The last active project, which matches the pattern (case insensitive) will be selected:
|
37
|
+
|
38
|
+
```sh
|
39
|
+
$ punched start "Punch*"
|
40
|
+
```
|
41
|
+
|
42
|
+
#### Stop Project
|
43
|
+
|
44
|
+
```sh
|
45
|
+
$ punched stop "Punch*"
|
46
|
+
```
|
47
|
+
|
48
|
+
#### Toggle
|
49
|
+
|
50
|
+
Toggle between start and stop:
|
51
|
+
|
52
|
+
```sh
|
53
|
+
$ punched toggle "Punch*"
|
54
|
+
```
|
55
|
+
|
56
|
+
#### Status
|
57
|
+
|
58
|
+
```sh
|
59
|
+
$ punched status "Punch*"
|
60
|
+
|
61
|
+
Punchcard (programming)
|
62
|
+
01:10:09
|
63
|
+
```
|
64
|
+
|
65
|
+
#### List details
|
66
|
+
|
67
|
+
```sh
|
68
|
+
$ punched details "Punch*"
|
69
|
+
|
70
|
+
Punchcard (programming) (stopped)
|
71
|
+
|
72
|
+
00:00:08 2017-05-07 08:16:06 - 2017-05-07 08:16:14
|
73
|
+
00:04:35 2017-05-07 08:22:02 - 2017-05-07 08:26:37
|
74
|
+
...
|
75
|
+
========
|
76
|
+
01:10:04 (total)
|
77
|
+
```
|
78
|
+
|
79
|
+
#### Set Hourly Rate
|
80
|
+
|
81
|
+
```sh
|
82
|
+
$ punched set "Punch*" hourlyRate 250€
|
83
|
+
```
|
84
|
+
|
85
|
+
#### Total time in seconds
|
86
|
+
|
87
|
+
```sh
|
88
|
+
$ punched total "Punch*"
|
89
|
+
```
|
90
|
+
|
91
|
+
#### Rename and delete Project
|
92
|
+
|
93
|
+
```sh
|
94
|
+
$ punched rename "Old Title" "New Title"
|
95
|
+
```
|
96
|
+
|
97
|
+
```sh
|
98
|
+
$ punched remove "Punchcard (programming)"
|
99
|
+
```
|
100
|
+
|
101
|
+
|
102
|
+
#### List all projects with total time in CSV format
|
103
|
+
|
104
|
+
```sh
|
105
|
+
$ punched all
|
106
|
+
|
107
|
+
"project","status","last active on","total duration","hourly rate","earnings"
|
108
|
+
"Website","stopped","2017-05-07 15:50:00","04:06:00","250.0 €","1025.00 €"
|
109
|
+
"Punchcard (programming)","stopped","2017-07-11 12:47:42","01:10:04","",""
|
110
|
+
```
|
111
|
+
|
112
|
+
You can use `all` with any other action as well, e.g. `punched all stop` to stop all running projects.
|
113
|
+
|
114
|
+
Hint: Use your favorite output formatter to get a nicer project summary of your choice; e.g. with [csv2md](https://www.npmjs.com/package/csv2md):
|
115
|
+
|
116
|
+
```sh
|
117
|
+
$ punched all | csv2md --pretty
|
118
|
+
|
119
|
+
| project | status | last active on | total duration | hourly rate | earnings |
|
120
|
+
|---------------------------|---------|---------------------|----------------|-------------|----------|
|
121
|
+
| Website | stopped | 2017-05-07 15:50:00 | 04:06:00 | 250.0 € | 1025.0 € |
|
122
|
+
| Punchcard (programming) | stopped | 2017-05-07 12:47:42 | 01:10:04 | | |
|
123
|
+
```
|
124
|
+
|
125
|
+
### Store projects files in a custom folder and sync them between computers
|
126
|
+
|
127
|
+
By default, PunchCard will store the data in `~/.punchcard/`. Define your custom destination with:
|
128
|
+
|
129
|
+
```sh
|
130
|
+
export PUNCHCARD_DIR=~/Nextcloud/punchcard
|
131
|
+
```
|
132
|
+
|
133
|
+
|
134
|
+
### Tests
|
135
|
+
|
136
|
+
```sh
|
137
|
+
$ bundle exec rspec
|
138
|
+
```
|
data/bin/punched
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require 'punchcard'
|
4
|
+
|
5
|
+
#
|
6
|
+
# CLI Wrapper
|
7
|
+
#
|
8
|
+
|
9
|
+
def available_actions
|
10
|
+
PunchCard.new(nil).public_methods(false).reject { |item| item.to_s.end_with?('=') }.sort
|
11
|
+
end
|
12
|
+
|
13
|
+
def action_available? action
|
14
|
+
available_actions.include? action.to_sym
|
15
|
+
end
|
16
|
+
|
17
|
+
def exit_with_error! msg
|
18
|
+
STDERR.puts msg
|
19
|
+
exit 1
|
20
|
+
end
|
21
|
+
|
22
|
+
def usage
|
23
|
+
"Usage: #{File.basename(__FILE__)} 'Name of my project' [#{available_actions.join('|')}]"
|
24
|
+
end
|
25
|
+
|
26
|
+
def all action
|
27
|
+
puts('"project","status","last active on","total duration","hourly rate","earnings"') if action == 'csv'
|
28
|
+
Dir[PunchCard::SETTINGS_DIR+'/*'].sort_by { |f| File.mtime(f) }.reverse.each do |file|
|
29
|
+
puts `ruby #{__FILE__} #{action} '#{File.basename(file)}'`
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
if ARGV.first == 'all'
|
34
|
+
all ARGV[1] ? ARGV[1] : 'csv'
|
35
|
+
exit
|
36
|
+
elsif ARGV.first && ['-h', '--help', 'help'].include?(ARGV.first)
|
37
|
+
puts usage
|
38
|
+
exit
|
39
|
+
end
|
40
|
+
|
41
|
+
selected_action = ARGV[0]
|
42
|
+
project_name = ARGV[1]
|
43
|
+
|
44
|
+
if selected_action
|
45
|
+
if action_available?(selected_action)
|
46
|
+
exit_with_error!("2nd argument has to be the project name, e.g.:\n#{usage}") if !project_name && selected_action != 'list'
|
47
|
+
punch_card = PunchCard.new project_name
|
48
|
+
begin
|
49
|
+
arguments = ARGV.drop(2)
|
50
|
+
if arguments.size > 0
|
51
|
+
puts punch_card.send(selected_action.to_s, *arguments)
|
52
|
+
else
|
53
|
+
puts punch_card.send(selected_action.to_s)
|
54
|
+
end
|
55
|
+
rescue PunchCardError => e
|
56
|
+
exit_with_error! "Error: #{e.message}"
|
57
|
+
end
|
58
|
+
else
|
59
|
+
exit_with_error! "Unrecognized action '#{selected_action || ''}'\n#{usage}"
|
60
|
+
end
|
61
|
+
end
|
data/lib/punchcard.rb
ADDED
@@ -0,0 +1,316 @@
|
|
1
|
+
# (c) 2017 by philipp staender
|
2
|
+
|
3
|
+
class PunchCardError < StandardError;
|
4
|
+
end
|
5
|
+
|
6
|
+
class PunchCard
|
7
|
+
|
8
|
+
SETTINGS_DIR = ENV['PUNCHCARD_DIR'] || File.expand_path('~/.punchcard')
|
9
|
+
HOURLY_RATE_PATTERN = /^\s*(\d+)([^\d]+)*\s*/i
|
10
|
+
TIME_POINT_PATTERN = /^(\d+)((\-)(\d+))*$/
|
11
|
+
META_KEY_PATTERN = /^([a-zA-Z0-9]+)\:\s*(.*)$/
|
12
|
+
VERSION = '0.1.0'
|
13
|
+
|
14
|
+
attr_accessor :project
|
15
|
+
|
16
|
+
def initialize project_name
|
17
|
+
@wilcard_for_filename = ''
|
18
|
+
@meta_data = {}
|
19
|
+
find_or_make_settings_dir
|
20
|
+
if project_name
|
21
|
+
self.project = project_name
|
22
|
+
find_or_make_file
|
23
|
+
read_project_data
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def start
|
28
|
+
output = []
|
29
|
+
if start_time && !end_time
|
30
|
+
output << "'#{project}' already started (#{humanized_total} total)"
|
31
|
+
output << "#{duration(start_time, timestamp)}"
|
32
|
+
else
|
33
|
+
output << "'#{project}' started (#{humanized_total} total)"
|
34
|
+
self.start_time = timestamp
|
35
|
+
end
|
36
|
+
output.join("\n")
|
37
|
+
end
|
38
|
+
|
39
|
+
def stop
|
40
|
+
output = []
|
41
|
+
if end_time
|
42
|
+
output << "'#{@project}' already stopped (#{humanized_total} total)"
|
43
|
+
elsif start_time
|
44
|
+
output << "'#{@project}' stopped (#{humanized_total} total)"
|
45
|
+
self.end_time = timestamp
|
46
|
+
else
|
47
|
+
output << "Nothing to stop"
|
48
|
+
end
|
49
|
+
output.join("\n")
|
50
|
+
end
|
51
|
+
|
52
|
+
def toggle
|
53
|
+
if active?
|
54
|
+
stop
|
55
|
+
else
|
56
|
+
start
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def status
|
61
|
+
project_exists_or_stop!
|
62
|
+
find_or_make_file
|
63
|
+
output = []
|
64
|
+
output << (project+" (#{running_status})\n")
|
65
|
+
output << humanized_total
|
66
|
+
output.join("\n")
|
67
|
+
end
|
68
|
+
|
69
|
+
def details
|
70
|
+
project_exists_or_stop!
|
71
|
+
find_or_make_file
|
72
|
+
output = []
|
73
|
+
output << project+" (#{running_status})\n\n"
|
74
|
+
project_data.map do |line|
|
75
|
+
points = line_to_time_points(line)
|
76
|
+
if points
|
77
|
+
starttime = points[0]
|
78
|
+
endtime = points[1] || timestamp
|
79
|
+
output << duration(starttime, endtime) + "\t" + self.class.format_time(Time.at(starttime)) + " - " + self.class.format_time(Time.at(endtime))
|
80
|
+
end
|
81
|
+
end
|
82
|
+
output << "========\n#{humanized_total}\t(total)"
|
83
|
+
output.join("\n")
|
84
|
+
end
|
85
|
+
|
86
|
+
def csv
|
87
|
+
project_exists_or_stop!
|
88
|
+
find_or_make_file
|
89
|
+
durations = []
|
90
|
+
project_data.map do |line|
|
91
|
+
points = line_to_time_points(line)
|
92
|
+
if points
|
93
|
+
starttime = points[0]
|
94
|
+
endtime = points[1] || timestamp
|
95
|
+
durations.push duration(starttime, endtime)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
'"'+[
|
99
|
+
@project,
|
100
|
+
running_status,
|
101
|
+
self.class.format_time(File.ctime(project_file)),
|
102
|
+
humanized_total,
|
103
|
+
hourly_rate ? hourly_rate[:hourlyRate].to_s + " #{hourly_rate[:currency]}" : '',
|
104
|
+
hourly_rate ? (hourly_rate[:hourlyRate] * total / 3600.0).round(2).to_s + " #{hourly_rate[:currency]}" : '',
|
105
|
+
].join('","') + '"'
|
106
|
+
end
|
107
|
+
|
108
|
+
def remove
|
109
|
+
if File.exists?(project_file)
|
110
|
+
File.delete(project_file)
|
111
|
+
"Deleted #{project_file}"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def rename new_project_name
|
116
|
+
old_filename = project_filename
|
117
|
+
data = project_data
|
118
|
+
data[0] = new_project_name
|
119
|
+
write_string_to_project_file! data.join("\n")
|
120
|
+
self.project = new_project_name
|
121
|
+
File.rename(old_filename, project_filename) && "#{old_filename} -> #{project_filename}"
|
122
|
+
end
|
123
|
+
|
124
|
+
def project= project_name
|
125
|
+
@project = project_name
|
126
|
+
if @project.end_with?('*')
|
127
|
+
@wilcard_for_filename = "*"
|
128
|
+
@project = @project.chomp("*")
|
129
|
+
end
|
130
|
+
@project.strip
|
131
|
+
end
|
132
|
+
|
133
|
+
def project
|
134
|
+
@project.strip
|
135
|
+
end
|
136
|
+
|
137
|
+
def set key, value
|
138
|
+
raise PunchCardError.new("Key '#{key}' can only be alphanumeric") unless key.match(/^[a-zA-Z0-9]+$/)
|
139
|
+
@meta_data[key.to_sym] = value
|
140
|
+
write_to_project_file!
|
141
|
+
@meta_data
|
142
|
+
end
|
143
|
+
|
144
|
+
def total
|
145
|
+
total = 0
|
146
|
+
project_data.map do |line|
|
147
|
+
points = line_to_time_points(line)
|
148
|
+
if points
|
149
|
+
starttime = points[0]
|
150
|
+
endtime = points[1] || timestamp
|
151
|
+
total += endtime - starttime
|
152
|
+
end
|
153
|
+
end
|
154
|
+
total
|
155
|
+
end
|
156
|
+
|
157
|
+
def self.format_time datetime
|
158
|
+
datetime.strftime('%F %T')
|
159
|
+
end
|
160
|
+
|
161
|
+
private
|
162
|
+
|
163
|
+
def hourly_rate
|
164
|
+
hourly_rate_found = @meta_data[:hourlyRate] && @meta_data[:hourlyRate].match(HOURLY_RATE_PATTERN)
|
165
|
+
if hourly_rate_found
|
166
|
+
{
|
167
|
+
hourlyRate: hourly_rate_found[1].to_f,
|
168
|
+
currency: hourly_rate_found[2] ? hourly_rate_found[2].strip : '',
|
169
|
+
}
|
170
|
+
else
|
171
|
+
nil
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def project_exists_or_stop!
|
176
|
+
raise PunchCardError.new("'#{@project}' does not exists") unless project_exist?
|
177
|
+
end
|
178
|
+
|
179
|
+
def active?
|
180
|
+
running_status == 'running'
|
181
|
+
end
|
182
|
+
|
183
|
+
def running_status
|
184
|
+
start_time && !end_time ? 'running' : 'stopped'
|
185
|
+
end
|
186
|
+
|
187
|
+
def humanized_total
|
188
|
+
humanize_duration total
|
189
|
+
end
|
190
|
+
|
191
|
+
def duration starttime, endtime
|
192
|
+
if starttime
|
193
|
+
humanize_duration endtime - starttime
|
194
|
+
else
|
195
|
+
humanize_duration 0
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def humanize_duration duration
|
200
|
+
hours = duration / (60 * 60)
|
201
|
+
minutes = (duration / 60) % 60
|
202
|
+
seconds = duration % 60
|
203
|
+
"#{decimal_digits(hours)}:#{decimal_digits(minutes)}:#{decimal_digits(seconds)}"
|
204
|
+
end
|
205
|
+
|
206
|
+
def decimal_digits digit
|
207
|
+
if digit.to_i < 10
|
208
|
+
"0#{digit}"
|
209
|
+
else
|
210
|
+
digit.to_s
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
def start_time
|
215
|
+
time_points ? time_points[0] : nil
|
216
|
+
end
|
217
|
+
|
218
|
+
def start_time= time
|
219
|
+
append_new_line time
|
220
|
+
end
|
221
|
+
|
222
|
+
def end_time= time
|
223
|
+
replace_last_line "#{start_time}-#{time}"
|
224
|
+
end
|
225
|
+
|
226
|
+
def end_time
|
227
|
+
time_points ? time_points[1] : nil
|
228
|
+
end
|
229
|
+
|
230
|
+
def time_points
|
231
|
+
line_to_time_points last_entry
|
232
|
+
end
|
233
|
+
|
234
|
+
def line_to_time_points line
|
235
|
+
matches = line.match(TIME_POINT_PATTERN)
|
236
|
+
matches ? [matches[1].to_i, matches[4] ? matches[4].to_i : nil] : nil
|
237
|
+
end
|
238
|
+
|
239
|
+
def last_entry
|
240
|
+
project_data.last
|
241
|
+
end
|
242
|
+
|
243
|
+
def timestamp
|
244
|
+
Time.now.to_i
|
245
|
+
end
|
246
|
+
|
247
|
+
def read_project_data
|
248
|
+
title = nil
|
249
|
+
meta_data = []
|
250
|
+
timestamps = []
|
251
|
+
i = 0
|
252
|
+
File.open(project_file, "r").each_line do |line|
|
253
|
+
line.strip!
|
254
|
+
if i.zero?
|
255
|
+
title = line
|
256
|
+
elsif line.match(META_KEY_PATTERN)
|
257
|
+
set line.match(META_KEY_PATTERN)[1], line.match(META_KEY_PATTERN)[2]
|
258
|
+
elsif line.match(TIME_POINT_PATTERN)
|
259
|
+
timestamps.push line
|
260
|
+
end
|
261
|
+
i += 1
|
262
|
+
end
|
263
|
+
@project = title if title
|
264
|
+
timestamps
|
265
|
+
end
|
266
|
+
|
267
|
+
def project_data
|
268
|
+
File.open(project_file).each_line.map { |line| line.strip }
|
269
|
+
end
|
270
|
+
|
271
|
+
def write_string_to_project_file! string
|
272
|
+
File.open(project_file, 'w') { |f| f.write(string) }
|
273
|
+
end
|
274
|
+
|
275
|
+
def write_to_project_file!
|
276
|
+
timestamps = project_data.select { |line| line.match(/^\d+/) }
|
277
|
+
meta_data_lines = @meta_data.map { |key, value| "#{key}: #{value}" }
|
278
|
+
write_string_to_project_file! [@project, meta_data_lines.join("\n"), timestamps].reject(&:empty?).join("\n")
|
279
|
+
end
|
280
|
+
|
281
|
+
def append_new_line line
|
282
|
+
open(project_file, 'a') { |f| f.puts("\n"+line.to_s.strip) }
|
283
|
+
end
|
284
|
+
|
285
|
+
def replace_last_line line
|
286
|
+
data = project_data
|
287
|
+
data[-1] = line
|
288
|
+
write_string_to_project_file! data.join("\n")
|
289
|
+
end
|
290
|
+
|
291
|
+
def project_file
|
292
|
+
Dir[project_filename + @wilcard_for_filename].sort_by { |f| File.mtime(f) }.reverse.first || project_filename
|
293
|
+
end
|
294
|
+
|
295
|
+
def project_filename
|
296
|
+
SETTINGS_DIR + "/#{sanitize_filename(@project)}"
|
297
|
+
end
|
298
|
+
|
299
|
+
def project_exist?
|
300
|
+
File.exists?(project_file)
|
301
|
+
end
|
302
|
+
|
303
|
+
def find_or_make_file
|
304
|
+
write_string_to_project_file!(@project+"\n") unless project_exist?
|
305
|
+
@project = project_data.first
|
306
|
+
end
|
307
|
+
|
308
|
+
def find_or_make_settings_dir
|
309
|
+
Dir.mkdir(SETTINGS_DIR) unless File.exists?(SETTINGS_DIR)
|
310
|
+
end
|
311
|
+
|
312
|
+
def sanitize_filename name
|
313
|
+
name.downcase.gsub(/(\\|\/)/, '').gsub(/[^0-9a-z.\-]/, '_')
|
314
|
+
end
|
315
|
+
|
316
|
+
end
|
data/punched.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "punchcard"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = 'punched'
|
7
|
+
s.version = PunchCard::VERSION
|
8
|
+
s.authors = ["Philipp Staender"]
|
9
|
+
s.email = ["philipp.staender@gmail.com"]
|
10
|
+
s.homepage = 'https://github.com/pstaender/punchcard'
|
11
|
+
s.summary = 'Punchcard Timetracker'
|
12
|
+
s.description = 'Minimal time tracking tool for cli'
|
13
|
+
s.license = 'GPL-3.0'
|
14
|
+
s.executables = ['punched']
|
15
|
+
s.default_executable = 'punched'
|
16
|
+
s.rubyforge_project = 'punched'
|
17
|
+
s.require_paths = ["lib"]
|
18
|
+
s.files = `git ls-files`.split("\n")
|
19
|
+
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
$:.push File.expand_path("../lib", __FILE__)
|
2
|
+
|
3
|
+
require "punchcard"
|
4
|
+
require 'securerandom'
|
5
|
+
|
6
|
+
def example_settings_dir
|
7
|
+
File.expand_path('./punchcard_test_data')
|
8
|
+
end
|
9
|
+
|
10
|
+
def setup_example_settings_dir
|
11
|
+
Dir.glob(example_settings_dir+'/*').each { |file| File.delete(file) }
|
12
|
+
PunchCard.send(:remove_const, :SETTINGS_DIR)
|
13
|
+
PunchCard.const_set(:SETTINGS_DIR, example_settings_dir)
|
14
|
+
end
|
15
|
+
|
16
|
+
describe PunchCard do
|
17
|
+
|
18
|
+
before do
|
19
|
+
setup_example_settings_dir
|
20
|
+
end
|
21
|
+
|
22
|
+
let(:example_project) { PunchCard.new('My Project') }
|
23
|
+
|
24
|
+
def random_project
|
25
|
+
PunchCard.new("My random Project #{SecureRandom.hex}")
|
26
|
+
end
|
27
|
+
|
28
|
+
def my_project_file filename = 'my_project'
|
29
|
+
File.open("#{example_settings_dir}/#{filename}", "r").read
|
30
|
+
end
|
31
|
+
|
32
|
+
def start_and_stop
|
33
|
+
example_project.start
|
34
|
+
sleep 0.1
|
35
|
+
example_project.stop
|
36
|
+
example_project
|
37
|
+
end
|
38
|
+
|
39
|
+
def two_seconds_tracking
|
40
|
+
example_project.start
|
41
|
+
sleep 2
|
42
|
+
example_project
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'should create a new PunchCard object' do
|
46
|
+
expect(example_project).to be_a(PunchCard)
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'should start a project' do
|
50
|
+
example_project.start
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'should start and stop a project' do
|
54
|
+
start_and_stop
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'should track a project time' do
|
58
|
+
timestamp = Time.now.to_i
|
59
|
+
start_and_stop
|
60
|
+
expect(my_project_file.lines.first.strip).to eq('My Project')
|
61
|
+
expect(my_project_file.lines.last.strip).to match('^'+timestamp.to_s[0..-3]+'\\d{2}\\-'+timestamp.to_s[0..-3]+'\\d{2}+$')
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'should calculate tracked total time' do
|
65
|
+
project = two_seconds_tracking
|
66
|
+
tracked_time = project.details.lines.last.match(/^\d{2}\:\d{2}\:(\d{2}).*total/)[1].to_i
|
67
|
+
expect(tracked_time).to be_between 1, 3
|
68
|
+
project = two_seconds_tracking
|
69
|
+
tracked_time = project.details.lines.last.match(/^\d{2}\:\d{2}\:(\d{2}).*total/)[1].to_i
|
70
|
+
expect(tracked_time).to be_between 3, 5
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'should toggle' do
|
74
|
+
project = start_and_stop
|
75
|
+
expect(project.status.lines.first).to match /stopped/
|
76
|
+
project.toggle
|
77
|
+
expect(project.status.lines.first).to match /running/
|
78
|
+
project.toggle
|
79
|
+
expect(project.status.lines.first).to match /stopped/
|
80
|
+
expect(my_project_file.lines.last).to match(/^\d+/)
|
81
|
+
expect(my_project_file.lines[-2]).to match(/^\d+/)
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'should read and write utf8 names' do
|
85
|
+
PunchCard.new "Playing Motörhead"
|
86
|
+
expect(my_project_file('playing_mot_rhead').strip).to eq("Playing Motörhead")
|
87
|
+
project = PunchCard.new "Playing*"
|
88
|
+
expect(project.project).to eq("Playing Motörhead")
|
89
|
+
end
|
90
|
+
|
91
|
+
it 'should set hourlyRate' do
|
92
|
+
project = start_and_stop
|
93
|
+
project.set 'hourlyRate', "1000 €"
|
94
|
+
expect(my_project_file.lines[1].strip).to eq("hourlyRate: 1000 €")
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'should calculate earnings' do
|
98
|
+
project = start_and_stop
|
99
|
+
project.set 'hourlyRate', "1000EURO"
|
100
|
+
project.toggle
|
101
|
+
sleep 2
|
102
|
+
project.toggle
|
103
|
+
project.toggle
|
104
|
+
sleep 2
|
105
|
+
project.toggle
|
106
|
+
expect(project.csv).to match /^"My Project","stopped","[0-9\-\s\:]+?","[0-9\:]+?","1000.0 EURO","1\.\d+ EURO"$/
|
107
|
+
end
|
108
|
+
|
109
|
+
it 'should track different projects simultanously' do
|
110
|
+
project_a = random_project
|
111
|
+
project_b = random_project
|
112
|
+
expect(project_a.project).not_to eq(project_b.project)
|
113
|
+
project_a.start
|
114
|
+
project_b.start
|
115
|
+
sleep 2
|
116
|
+
project_a.stop
|
117
|
+
sleep 2
|
118
|
+
project_b.stop
|
119
|
+
expect(project_b.total.to_i - project_a.total.to_i).to be_between(2,3)
|
120
|
+
end
|
121
|
+
|
122
|
+
it 'should load latest project by wildcard' do
|
123
|
+
project_a = random_project
|
124
|
+
project = PunchCard.new "My random*"
|
125
|
+
expect(project.project).to eq(project_a.project)
|
126
|
+
sleep 1
|
127
|
+
project_b = random_project
|
128
|
+
project = PunchCard.new "My random*"
|
129
|
+
expect(project.project).to eq(project_b.project)
|
130
|
+
expect(project.project).not_to eq(project_a.project)
|
131
|
+
end
|
132
|
+
|
133
|
+
it 'should rename' do
|
134
|
+
project = example_project
|
135
|
+
content = my_project_file
|
136
|
+
project.rename 'Renamed Project'
|
137
|
+
expect(File.open("#{example_settings_dir}/renamed_project", "r").read.strip).to eq(content.strip.sub(/My Project/, 'Renamed Project'))
|
138
|
+
expect(File.exists?("#{example_settings_dir}/my_project")).to be_falsey
|
139
|
+
expect(project.project).to eq('Renamed Project')
|
140
|
+
project.start
|
141
|
+
sleep 0.1
|
142
|
+
project.stop
|
143
|
+
content = File.open("#{example_settings_dir}/renamed_project", "r").read.strip
|
144
|
+
project.rename 'Other Project'
|
145
|
+
expect(File.open("#{example_settings_dir}/other_project", "r").read.strip).to eq(content.strip.sub(/Renamed Project/, 'Other Project'))
|
146
|
+
expect(File.exists?("#{example_settings_dir}/renamed_project")).to be_falsey
|
147
|
+
end
|
148
|
+
|
149
|
+
it 'should remove' do
|
150
|
+
project = example_project
|
151
|
+
expect(File.exists?("#{example_settings_dir}/my_project")).to be_truthy
|
152
|
+
project.remove
|
153
|
+
expect(File.exists?("#{example_settings_dir}/my_project")).to be_falsey
|
154
|
+
end
|
155
|
+
|
156
|
+
end
|