time-sheet 1.0.0 → 1.2.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.
- checksums.yaml +4 -4
- data/README.md +23 -0
- data/lib/time_sheet/time/cmd.rb +57 -16
- data/lib/time_sheet/time/entry.rb +62 -5
- data/lib/time_sheet/time/util.rb +6 -0
- data/lib/time_sheet/time.rb +45 -24
- data/lib/time_sheet/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fe61ceab6f7a30eaeb9a0e54854776f8dc09b1ba55f4578e98937b9ad7669388
|
|
4
|
+
data.tar.gz: 3fed81fb509d8df3d81118364bad0e6deaec9163ad75ecd7adfe128e432534ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f7bf35eb5afa3c311ccc945b4db80365425e0d5547e1e62d2ddd79942a25022b3604f950651892052d84b87a741ee196b1feb5129bd49642e1f9d771b21bde0
|
|
7
|
+
data.tar.gz: e8aef98393c7845993e87fc39703981891943e01847edd74451d5eabe9edb138218a5fdfcacdcaf822ffe0567809167639cca27c60512da56d57981d82dba299
|
data/README.md
CHANGED
|
@@ -5,6 +5,11 @@ generate various statistics suitable for invoices and project effort estimates.
|
|
|
5
5
|
|
|
6
6
|
## Changelog
|
|
7
7
|
|
|
8
|
+
v1.0.0
|
|
9
|
+
|
|
10
|
+
* input files need to be .xlsx files now because we needed to replace the
|
|
11
|
+
spreadsheet parser, see [#1](https://github.com/moritzschepp/time-sheet/issues/1)
|
|
12
|
+
|
|
8
13
|
v0.9.0
|
|
9
14
|
|
|
10
15
|
* introduced employee data column: To use it, add a column "employee" to the
|
|
@@ -26,6 +31,24 @@ The gem bundles an executable which includes a help command, just run
|
|
|
26
31
|
A config file at `~/.time-sheet.conf` is read if it exists. The only possible
|
|
27
32
|
option is `location: /path/to/excel/sheets`. It can be specified multiple times.
|
|
28
33
|
|
|
34
|
+
The spreadsheet may carry a `tags` column holding a comma-separated list of tags
|
|
35
|
+
per row. A blank `tags` cell is inherited from the previous row when the project,
|
|
36
|
+
activity and description are unchanged, so a run of rows describing one task only
|
|
37
|
+
needs to be tagged once. The `report` and `invoice` commands append a `Tags:`
|
|
38
|
+
section showing how much of the reported time falls on each tag.
|
|
39
|
+
|
|
40
|
+
Tag times can be scaled per tag with `-b` / `--tag-boosts`, e.g.
|
|
41
|
+
`time-sheet.rb -b ai:1.4,physical:0.75`. Each entry's time is multiplied by the
|
|
42
|
+
product of the factors of the tags it carries; untagged or unboosted entries are
|
|
43
|
+
left unchanged. The `Tags:` section always reports unboosted time.
|
|
44
|
+
|
|
45
|
+
The rendered descriptions can be cleaned up with `--filter-descriptions`, a
|
|
46
|
+
single unanchored regular expression whose matches are removed from the output
|
|
47
|
+
(reapplied until no match remains):
|
|
48
|
+
`time-sheet.rb --filter-descriptions "mail to (Tom|Lana)"` turns *"mail to Tom
|
|
49
|
+
about the report"* into *" about the report"*. It only affects what is printed;
|
|
50
|
+
no rows are excluded.
|
|
51
|
+
|
|
29
52
|
### Processing data with other tools
|
|
30
53
|
|
|
31
54
|
You can simply let the tool output a full list of time entries that are easy to
|
data/lib/time_sheet/time/cmd.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
|
|
1
3
|
require 'slop'
|
|
2
4
|
require 'time'
|
|
3
5
|
|
|
4
6
|
class TimeSheet::Time::Cmd
|
|
5
7
|
def run
|
|
6
|
-
|
|
8
|
+
options[:tag_boosts] = parse_tag_boosts(options[:tag_boosts])
|
|
7
9
|
|
|
8
10
|
if d = options[:from]
|
|
9
11
|
if d.match(/^\d\d?-\d\d?$/)
|
|
@@ -85,17 +87,22 @@ class TimeSheet::Time::Cmd
|
|
|
85
87
|
end
|
|
86
88
|
end
|
|
87
89
|
|
|
88
|
-
def
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
def config
|
|
91
|
+
defaults = {
|
|
92
|
+
'location' => "#{ENV['HOME']}/time",
|
|
93
|
+
'carry_over_activity' => 'previous'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
file = "#{ENV['HOME']}/.time-sheet.conf"
|
|
97
|
+
return defaults unless File.exist?(file)
|
|
98
|
+
|
|
99
|
+
result = YAML.load_file(file)
|
|
100
|
+
result = defaults.merge(result)
|
|
101
|
+
|
|
102
|
+
unless result['location'].is_a?(Array)
|
|
103
|
+
result['location'] = [result['location']]
|
|
97
104
|
end
|
|
98
|
-
|
|
105
|
+
|
|
99
106
|
result
|
|
100
107
|
end
|
|
101
108
|
|
|
@@ -114,15 +121,23 @@ class TimeSheet::Time::Cmd
|
|
|
114
121
|
o.boolean '-h', '--help', 'show help'
|
|
115
122
|
o.boolean '--version', 'show the version'
|
|
116
123
|
o.array('-l', '--location', 'a location to gather data from (file, directory or google docs share-url)',
|
|
117
|
-
default:
|
|
124
|
+
default: config['location']
|
|
118
125
|
)
|
|
119
126
|
o.string '-f', '--from', 'ignore entries older than the date given'
|
|
120
127
|
o.string '-t', '--to', 'ignore entries more recent than the date given'
|
|
121
128
|
o.string '-p', '--project', 'take only entries of this project into account'
|
|
122
129
|
o.string '-a', '--activity', 'take only entries of this activity into account'
|
|
123
130
|
o.string '--tags', 'filter by tag (comma separated, not case sensitive, prefix tag with ! to exclude)'
|
|
131
|
+
o.string '-b', '--tag-boosts', 'multiply the time of entries by a factor per tag, e.g. ai:1.4,physical:0.75'
|
|
124
132
|
o.string '-d', '--description', 'consider only entries matching this description'
|
|
133
|
+
o.string '--filter-descriptions', 'drop the parts of output descriptions matching this unanchored regex'
|
|
125
134
|
o.string '-e', '--employee', 'consider only entries for this employee'
|
|
135
|
+
o.string(
|
|
136
|
+
'--carry-over-activity',
|
|
137
|
+
'mode for carrying over activity (previous|same-description)',
|
|
138
|
+
default: config['carry_over_activity']
|
|
139
|
+
)
|
|
140
|
+
o.string '--default-activity', 'default activity', default: config['default_activity']
|
|
126
141
|
o.float '-r', '--rate', 'use an alternative hourly rate (default: 80.0)', default: 80.00
|
|
127
142
|
o.boolean '-s', '--summary', 'when reporting, add summary section'
|
|
128
143
|
o.boolean '--trim', 'compact the output for processing as CSV', default: false
|
|
@@ -138,6 +153,15 @@ class TimeSheet::Time::Cmd
|
|
|
138
153
|
options.arguments.shift || 'default'
|
|
139
154
|
end
|
|
140
155
|
|
|
156
|
+
def parse_tag_boosts(value)
|
|
157
|
+
return {} if value.nil? || value.to_s.strip.empty?
|
|
158
|
+
|
|
159
|
+
value.split(/\s*,\s*/).each_with_object({}) do |pair, result|
|
|
160
|
+
tag, factor = pair.split(/\s*:\s*/, 2)
|
|
161
|
+
result[tag.to_s.downcase] = factor.to_f
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
141
165
|
def convert_to_time
|
|
142
166
|
if options[:from]
|
|
143
167
|
options[:from] = options[:from].to_time
|
|
@@ -148,6 +172,8 @@ class TimeSheet::Time::Cmd
|
|
|
148
172
|
end
|
|
149
173
|
|
|
150
174
|
def verify
|
|
175
|
+
TimeSheet.options = options
|
|
176
|
+
|
|
151
177
|
convert_to_time
|
|
152
178
|
|
|
153
179
|
entries = TimeSheet::Time::Parser.new(options[:location]).entries
|
|
@@ -170,7 +196,8 @@ class TimeSheet::Time::Cmd
|
|
|
170
196
|
def invoice
|
|
171
197
|
convert_to_time
|
|
172
198
|
|
|
173
|
-
|
|
199
|
+
entries = TimeSheet::Time.matching_entries(options)
|
|
200
|
+
data = TimeSheet::Time.invoice_entries(entries, options)
|
|
174
201
|
|
|
175
202
|
data.each do |package|
|
|
176
203
|
tp = TimeSheet::TablePrinter.new package, options
|
|
@@ -178,7 +205,7 @@ class TimeSheet::Time::Cmd
|
|
|
178
205
|
puts "\n"
|
|
179
206
|
end
|
|
180
207
|
|
|
181
|
-
if options[:package]
|
|
208
|
+
if options[:package].to_f > 0
|
|
182
209
|
package = (data.last.nil? ? 0 : data.last.map{|entry| entry[1]}.sum)
|
|
183
210
|
total = options[:package] * 60
|
|
184
211
|
percent = (package / total.to_f * 100)
|
|
@@ -189,6 +216,8 @@ class TimeSheet::Time::Cmd
|
|
|
189
216
|
"#{percent.round 2}%)"
|
|
190
217
|
].join(' ')
|
|
191
218
|
end
|
|
219
|
+
|
|
220
|
+
print_tags(TimeSheet::Time.tag_totals(entries), entries.sum(&:raw_duration))
|
|
192
221
|
end
|
|
193
222
|
|
|
194
223
|
def report
|
|
@@ -222,7 +251,7 @@ class TimeSheet::Time::Cmd
|
|
|
222
251
|
TimeSheet::Time::Util.hours(pdata['total']),
|
|
223
252
|
TimeSheet::Time::Util.price(pdata['total'], options[:rate])
|
|
224
253
|
]
|
|
225
|
-
|
|
254
|
+
|
|
226
255
|
pdata['activities'].sort_by{|k, v| v}.reverse.to_h.each do |aname, atotal|
|
|
227
256
|
tdata << [
|
|
228
257
|
'',
|
|
@@ -248,6 +277,18 @@ class TimeSheet::Time::Cmd
|
|
|
248
277
|
"h/month(30 days): #{data['averages']['hours_per_month'].round(2)}"
|
|
249
278
|
].join(', ')
|
|
250
279
|
end
|
|
280
|
+
|
|
281
|
+
print_tags(data['tags'], data['raw_total'])
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def print_tags(tags, total)
|
|
285
|
+
tags ||= {}
|
|
286
|
+
return if tags.empty?
|
|
287
|
+
|
|
288
|
+
puts "\nTags:"
|
|
289
|
+
tags.sort_by{|tag, minutes| [-minutes, tag]}.each do |tag, minutes|
|
|
290
|
+
puts "#{tag}: #{TimeSheet::Time::Util.hours(minutes)} h (#{TimeSheet::Time::Util.share(minutes, total)}%)"
|
|
291
|
+
end
|
|
251
292
|
end
|
|
252
293
|
|
|
253
|
-
end
|
|
294
|
+
end
|
|
@@ -14,7 +14,20 @@ class TimeSheet::Time::Entry
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def activity
|
|
17
|
-
|
|
17
|
+
if !@data['activity']
|
|
18
|
+
carry_mode = TimeSheet.options[:carry_over_activity]
|
|
19
|
+
use_prev = (
|
|
20
|
+
carry_mode == 'previous' ||
|
|
21
|
+
(carry_mode == 'same-description' && self.prev.description == description)
|
|
22
|
+
)
|
|
23
|
+
if use_prev
|
|
24
|
+
@data['activity'] = self.prev.activity
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@data['activity'] ||= TimeSheet.options[:default_activity] || ''
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@data['activity']
|
|
18
31
|
end
|
|
19
32
|
|
|
20
33
|
def description
|
|
@@ -96,11 +109,37 @@ class TimeSheet::Time::Entry
|
|
|
96
109
|
# end
|
|
97
110
|
|
|
98
111
|
def duration
|
|
112
|
+
raw_duration * factor
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def raw_duration
|
|
99
116
|
(self.end - self.start) / 60
|
|
100
117
|
end
|
|
101
118
|
|
|
119
|
+
# Multiplier applied to this entry's time: the product of the boost factors of
|
|
120
|
+
# all its tags. Tags without a boost factor leave the time unchanged.
|
|
121
|
+
def factor
|
|
122
|
+
boosts = (TimeSheet.options && TimeSheet.options[:tag_boosts]) || {}
|
|
123
|
+
tags.inject(1.0){|f, tag| f * (boosts[tag] || 1.0)}
|
|
124
|
+
end
|
|
125
|
+
|
|
102
126
|
def tags
|
|
103
|
-
self.class.parse_tags(
|
|
127
|
+
self.class.parse_tags(tag_source)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Tags carry over from the previous entry when project, activity and
|
|
131
|
+
# description are the same, so a group of rows describing one task only needs
|
|
132
|
+
# to be tagged on its first row. Any other row has no tags.
|
|
133
|
+
def tag_source
|
|
134
|
+
raw = @data['tags']
|
|
135
|
+
return raw if raw && !raw.to_s.strip.empty?
|
|
136
|
+
|
|
137
|
+
if self.prev &&
|
|
138
|
+
self.prev.project == project &&
|
|
139
|
+
self.prev.activity == activity &&
|
|
140
|
+
self.prev.description == description
|
|
141
|
+
self.prev.tag_source
|
|
142
|
+
end
|
|
104
143
|
end
|
|
105
144
|
|
|
106
145
|
def working_day?
|
|
@@ -152,7 +191,7 @@ class TimeSheet::Time::Entry
|
|
|
152
191
|
raise TimeSheet::Time::Exception.new('time entry has no start')
|
|
153
192
|
end
|
|
154
193
|
|
|
155
|
-
if
|
|
194
|
+
if raw_duration <= 0
|
|
156
195
|
raise TimeSheet::Time::Exception.new('time entry duration is 0 or less')
|
|
157
196
|
end
|
|
158
197
|
|
|
@@ -168,10 +207,28 @@ class TimeSheet::Time::Entry
|
|
|
168
207
|
def to_row
|
|
169
208
|
[
|
|
170
209
|
employee, date, start, self.end, duration.to_i, project, activity,
|
|
171
|
-
description
|
|
210
|
+
self.class.filter_description(description, TimeSheet.options)
|
|
172
211
|
]
|
|
173
212
|
end
|
|
174
213
|
|
|
214
|
+
# Drops the parts of a description matching the --filter-descriptions regex,
|
|
215
|
+
# repeating until no further match remains. The match is unanchored; rows are
|
|
216
|
+
# never excluded, only the rendered text is changed.
|
|
217
|
+
def self.filter_description(value, options)
|
|
218
|
+
pattern = options && options[:filter_descriptions]
|
|
219
|
+
return value if pattern.nil? || pattern.to_s.empty?
|
|
220
|
+
|
|
221
|
+
regexp = Regexp.new(pattern)
|
|
222
|
+
result = value.to_s
|
|
223
|
+
loop do
|
|
224
|
+
filtered = result.gsub(regexp, '')
|
|
225
|
+
break if filtered == result
|
|
226
|
+
|
|
227
|
+
result = filtered
|
|
228
|
+
end
|
|
229
|
+
result
|
|
230
|
+
end
|
|
231
|
+
|
|
175
232
|
def to_s
|
|
176
233
|
values = [
|
|
177
234
|
employee,
|
|
@@ -215,4 +272,4 @@ class TimeSheet::Time::Entry
|
|
|
215
272
|
(string || '').to_s.downcase.split(/\s*,\s*/).map{|t| t.strip}
|
|
216
273
|
end
|
|
217
274
|
|
|
218
|
-
end
|
|
275
|
+
end
|
data/lib/time_sheet/time/util.rb
CHANGED
data/lib/time_sheet/time.rb
CHANGED
|
@@ -11,36 +11,55 @@ module TimeSheet::Time
|
|
|
11
11
|
results = {
|
|
12
12
|
'entries' => [],
|
|
13
13
|
'total' => 0.0,
|
|
14
|
-
'
|
|
14
|
+
'raw_total' => 0.0,
|
|
15
|
+
'projects' => {},
|
|
16
|
+
'tags' => {}
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
results['projects'][e.project]['activities'][e.activity] ||= 0.0
|
|
27
|
-
results['projects'][e.project]['activities'][e.activity] += e.duration
|
|
28
|
-
|
|
29
|
-
results['entries'] << e.to_row
|
|
30
|
-
end
|
|
19
|
+
matching = matching_entries(options)
|
|
20
|
+
matching.each do |e|
|
|
21
|
+
results['total'] += e.duration
|
|
22
|
+
results['projects'][e.project] ||= {'total' => 0.0, 'activities' => {}}
|
|
23
|
+
results['projects'][e.project]['total'] += e.duration
|
|
24
|
+
results['projects'][e.project]['activities'][e.activity] ||= 0.0
|
|
25
|
+
results['projects'][e.project]['activities'][e.activity] += e.duration
|
|
26
|
+
|
|
27
|
+
results['entries'] << e.to_row
|
|
31
28
|
end
|
|
32
29
|
|
|
30
|
+
results['tags'] = tag_totals(matching)
|
|
31
|
+
results['raw_total'] = matching.sum(&:raw_duration)
|
|
32
|
+
|
|
33
33
|
averages(results, options)
|
|
34
34
|
|
|
35
35
|
results
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# All entries matching the given filters. Sets TimeSheet.options first so that
|
|
39
|
+
# the carry-over rules apply while the entries are read.
|
|
40
|
+
def self.matching_entries(options)
|
|
41
|
+
TimeSheet.options = options
|
|
42
|
+
Parser.new(options[:location]).entries.select { |e| e.matches?(options) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Sums the unboosted duration of entries carrying a tag, so the tag breakdown
|
|
46
|
+
# reflects actual time regardless of any --tag-boosts factors. An entry with
|
|
47
|
+
# several tags contributes its full duration to each of them, so the per-tag
|
|
48
|
+
# totals do not necessarily add up to the overall total.
|
|
49
|
+
def self.tag_totals(entries)
|
|
50
|
+
totals = Hash.new(0.0)
|
|
51
|
+
entries.each do |e|
|
|
52
|
+
e.tags.each { |tag| totals[tag] += e.raw_duration }
|
|
53
|
+
end
|
|
54
|
+
totals
|
|
55
|
+
end
|
|
56
|
+
|
|
38
57
|
def self.averages(results, options)
|
|
39
58
|
days = 1
|
|
40
59
|
|
|
41
60
|
unless results['entries'].empty?
|
|
42
61
|
time = (
|
|
43
|
-
(options[:to] || Util.day_end) -
|
|
62
|
+
(options[:to] || Util.day_end) -
|
|
44
63
|
(options[:from] || results['entries'].first[1].to_time)
|
|
45
64
|
).to_i
|
|
46
65
|
days = (time.to_f / 60 / 60 / 24).round
|
|
@@ -65,15 +84,17 @@ module TimeSheet::Time
|
|
|
65
84
|
end
|
|
66
85
|
|
|
67
86
|
def self.invoice(options)
|
|
87
|
+
invoice_entries(matching_entries(options), options)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.invoice_entries(entries, options)
|
|
68
91
|
grouped = {}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
grouped[[e.date, e.description]] += e.duration.to_i
|
|
73
|
-
end
|
|
92
|
+
entries.each do |e|
|
|
93
|
+
grouped[[e.date, e.description]] ||= 0
|
|
94
|
+
grouped[[e.date, e.description]] += e.duration.to_i
|
|
74
95
|
end
|
|
75
96
|
rows = []
|
|
76
|
-
grouped.each{|k, d| rows << [k.first, d, k.last]}
|
|
97
|
+
grouped.each{|k, d| rows << [k.first, d, Entry.filter_description(k.last, options)]}
|
|
77
98
|
packages = [[]]
|
|
78
99
|
ptotal = 0
|
|
79
100
|
|
|
@@ -102,7 +123,7 @@ module TimeSheet::Time
|
|
|
102
123
|
next
|
|
103
124
|
end
|
|
104
125
|
end
|
|
105
|
-
|
|
126
|
+
|
|
106
127
|
packages.last << row
|
|
107
128
|
ptotal += row[1]
|
|
108
129
|
end
|
|
@@ -131,4 +152,4 @@ module TimeSheet::Time
|
|
|
131
152
|
packages
|
|
132
153
|
end
|
|
133
154
|
|
|
134
|
-
end
|
|
155
|
+
end
|
data/lib/time_sheet/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: time-sheet
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Moritz Schepp
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-10-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: roo
|
|
@@ -141,7 +141,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
141
141
|
- !ruby/object:Gem::Version
|
|
142
142
|
version: '0'
|
|
143
143
|
requirements: []
|
|
144
|
-
rubygems_version: 3.5.
|
|
144
|
+
rubygems_version: 3.5.22
|
|
145
145
|
signing_key:
|
|
146
146
|
specification_version: 4
|
|
147
147
|
summary: a time tracking solution based on spreadsheets
|