punched 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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