punched 0.1.0

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.
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