time-sheet 1.1.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 +18 -0
- data/lib/time_sheet/time/cmd.rb +30 -2
- data/lib/time_sheet/time/entry.rb +47 -3
- data/lib/time_sheet/time/util.rb +6 -0
- data/lib/time_sheet/time.rb +42 -25
- data/lib/time_sheet/version.rb +1 -1
- metadata +2 -2
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
|
@@ -31,6 +31,24 @@ The gem bundles an executable which includes a help command, just run
|
|
|
31
31
|
A config file at `~/.time-sheet.conf` is read if it exists. The only possible
|
|
32
32
|
option is `location: /path/to/excel/sheets`. It can be specified multiple times.
|
|
33
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
|
+
|
|
34
52
|
### Processing data with other tools
|
|
35
53
|
|
|
36
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
|
@@ -5,6 +5,8 @@ require 'time'
|
|
|
5
5
|
|
|
6
6
|
class TimeSheet::Time::Cmd
|
|
7
7
|
def run
|
|
8
|
+
options[:tag_boosts] = parse_tag_boosts(options[:tag_boosts])
|
|
9
|
+
|
|
8
10
|
if d = options[:from]
|
|
9
11
|
if d.match(/^\d\d?-\d\d?$/)
|
|
10
12
|
d = "#{TimeSheet::Time::Util.now.year}-#{d}"
|
|
@@ -126,7 +128,9 @@ class TimeSheet::Time::Cmd
|
|
|
126
128
|
o.string '-p', '--project', 'take only entries of this project into account'
|
|
127
129
|
o.string '-a', '--activity', 'take only entries of this activity into account'
|
|
128
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'
|
|
129
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'
|
|
130
134
|
o.string '-e', '--employee', 'consider only entries for this employee'
|
|
131
135
|
o.string(
|
|
132
136
|
'--carry-over-activity',
|
|
@@ -149,6 +153,15 @@ class TimeSheet::Time::Cmd
|
|
|
149
153
|
options.arguments.shift || 'default'
|
|
150
154
|
end
|
|
151
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
|
+
|
|
152
165
|
def convert_to_time
|
|
153
166
|
if options[:from]
|
|
154
167
|
options[:from] = options[:from].to_time
|
|
@@ -183,7 +196,8 @@ class TimeSheet::Time::Cmd
|
|
|
183
196
|
def invoice
|
|
184
197
|
convert_to_time
|
|
185
198
|
|
|
186
|
-
|
|
199
|
+
entries = TimeSheet::Time.matching_entries(options)
|
|
200
|
+
data = TimeSheet::Time.invoice_entries(entries, options)
|
|
187
201
|
|
|
188
202
|
data.each do |package|
|
|
189
203
|
tp = TimeSheet::TablePrinter.new package, options
|
|
@@ -191,7 +205,7 @@ class TimeSheet::Time::Cmd
|
|
|
191
205
|
puts "\n"
|
|
192
206
|
end
|
|
193
207
|
|
|
194
|
-
if options[:package]
|
|
208
|
+
if options[:package].to_f > 0
|
|
195
209
|
package = (data.last.nil? ? 0 : data.last.map{|entry| entry[1]}.sum)
|
|
196
210
|
total = options[:package] * 60
|
|
197
211
|
percent = (package / total.to_f * 100)
|
|
@@ -202,6 +216,8 @@ class TimeSheet::Time::Cmd
|
|
|
202
216
|
"#{percent.round 2}%)"
|
|
203
217
|
].join(' ')
|
|
204
218
|
end
|
|
219
|
+
|
|
220
|
+
print_tags(TimeSheet::Time.tag_totals(entries), entries.sum(&:raw_duration))
|
|
205
221
|
end
|
|
206
222
|
|
|
207
223
|
def report
|
|
@@ -261,6 +277,18 @@ class TimeSheet::Time::Cmd
|
|
|
261
277
|
"h/month(30 days): #{data['averages']['hours_per_month'].round(2)}"
|
|
262
278
|
].join(', ')
|
|
263
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
|
|
264
292
|
end
|
|
265
293
|
|
|
266
294
|
end
|
|
@@ -109,11 +109,37 @@ class TimeSheet::Time::Entry
|
|
|
109
109
|
# end
|
|
110
110
|
|
|
111
111
|
def duration
|
|
112
|
+
raw_duration * factor
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def raw_duration
|
|
112
116
|
(self.end - self.start) / 60
|
|
113
117
|
end
|
|
114
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
|
+
|
|
115
126
|
def tags
|
|
116
|
-
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
|
|
117
143
|
end
|
|
118
144
|
|
|
119
145
|
def working_day?
|
|
@@ -165,7 +191,7 @@ class TimeSheet::Time::Entry
|
|
|
165
191
|
raise TimeSheet::Time::Exception.new('time entry has no start')
|
|
166
192
|
end
|
|
167
193
|
|
|
168
|
-
if
|
|
194
|
+
if raw_duration <= 0
|
|
169
195
|
raise TimeSheet::Time::Exception.new('time entry duration is 0 or less')
|
|
170
196
|
end
|
|
171
197
|
|
|
@@ -181,10 +207,28 @@ class TimeSheet::Time::Entry
|
|
|
181
207
|
def to_row
|
|
182
208
|
[
|
|
183
209
|
employee, date, start, self.end, duration.to_i, project, activity,
|
|
184
|
-
description
|
|
210
|
+
self.class.filter_description(description, TimeSheet.options)
|
|
185
211
|
]
|
|
186
212
|
end
|
|
187
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
|
+
|
|
188
232
|
def to_s
|
|
189
233
|
values = [
|
|
190
234
|
employee,
|
data/lib/time_sheet/time/util.rb
CHANGED
data/lib/time_sheet/time.rb
CHANGED
|
@@ -8,35 +8,52 @@ module TimeSheet::Time
|
|
|
8
8
|
autoload :Util, 'time_sheet/time/util'
|
|
9
9
|
|
|
10
10
|
def self.report(options)
|
|
11
|
-
TimeSheet.options = options
|
|
12
|
-
|
|
13
11
|
results = {
|
|
14
12
|
'entries' => [],
|
|
15
13
|
'total' => 0.0,
|
|
16
|
-
'
|
|
14
|
+
'raw_total' => 0.0,
|
|
15
|
+
'projects' => {},
|
|
16
|
+
'tags' => {}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
results['projects'][e.project]['activities'][e.activity] ||= 0.0
|
|
29
|
-
results['projects'][e.project]['activities'][e.activity] += e.duration
|
|
30
|
-
|
|
31
|
-
results['entries'] << e.to_row
|
|
32
|
-
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
|
|
33
28
|
end
|
|
34
29
|
|
|
30
|
+
results['tags'] = tag_totals(matching)
|
|
31
|
+
results['raw_total'] = matching.sum(&:raw_duration)
|
|
32
|
+
|
|
35
33
|
averages(results, options)
|
|
36
34
|
|
|
37
35
|
results
|
|
38
36
|
end
|
|
39
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
|
+
|
|
40
57
|
def self.averages(results, options)
|
|
41
58
|
days = 1
|
|
42
59
|
|
|
@@ -67,17 +84,17 @@ module TimeSheet::Time
|
|
|
67
84
|
end
|
|
68
85
|
|
|
69
86
|
def self.invoice(options)
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
invoice_entries(matching_entries(options), options)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.invoice_entries(entries, options)
|
|
72
91
|
grouped = {}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
grouped[[e.date, e.description]] += e.duration.to_i
|
|
77
|
-
end
|
|
92
|
+
entries.each do |e|
|
|
93
|
+
grouped[[e.date, e.description]] ||= 0
|
|
94
|
+
grouped[[e.date, e.description]] += e.duration.to_i
|
|
78
95
|
end
|
|
79
96
|
rows = []
|
|
80
|
-
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)]}
|
|
81
98
|
packages = [[]]
|
|
82
99
|
ptotal = 0
|
|
83
100
|
|
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
|