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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 35406dddfbbe1e74c061171bea36962f3d5632a6cedc75e846a66e53fc2fbd9e
4
- data.tar.gz: deab85ae87457cab458141d8a4987ca592a07d0860405d2ecea1190a316e2b9e
3
+ metadata.gz: fe61ceab6f7a30eaeb9a0e54854776f8dc09b1ba55f4578e98937b9ad7669388
4
+ data.tar.gz: 3fed81fb509d8df3d81118364bad0e6deaec9163ad75ecd7adfe128e432534ae
5
5
  SHA512:
6
- metadata.gz: e262b17d41fbc239710af28d16d6fa00334d5cb691489e0b017f8864db76ce7b4b5ac7ba7fec53d39b821cda78522bd7fc234680ece63c184adf7b9275dcddee
7
- data.tar.gz: 03eb5990ebcae8f95d4931a1dcaa7b5469e54f6eac61fcfe20a752c8e0c75146028b066153fed8bb8d46afeff5fa417235418db7ea82120694f224626c9ccbcc
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
@@ -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
- data = TimeSheet::Time.invoice(options)
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(@data['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 duration <= 0
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,
@@ -58,4 +58,10 @@ module TimeSheet::Time::Util
58
58
  (self.hours(duration) * rate).round(2)
59
59
  end
60
60
 
61
+ def self.share(part, whole)
62
+ return 0.0 if whole.nil? || whole.to_f.zero?
63
+
64
+ (part / whole.to_f * 100).round(1)
65
+ end
66
+
61
67
  end
@@ -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
- 'projects' => {}
14
+ 'raw_total' => 0.0,
15
+ 'projects' => {},
16
+ 'tags' => {}
17
17
  }
18
18
 
19
- x = nil
20
- Parser.new(options[:location]).entries.each do |e|
21
- unless x
22
- x = true
23
- end
24
- if e.matches?(options)
25
- results['total'] += e.duration
26
- results['projects'][e.project] ||= {'total' => 0.0, 'activities' => {}}
27
- results['projects'][e.project]['total'] += e.duration
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
- TimeSheet.options = options
71
-
87
+ invoice_entries(matching_entries(options), options)
88
+ end
89
+
90
+ def self.invoice_entries(entries, options)
72
91
  grouped = {}
73
- Parser.new(options[:location]).entries.each do |e|
74
- if e.matches?(options)
75
- grouped[[e.date, e.description]] ||= 0
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
 
@@ -1,3 +1,3 @@
1
1
  module TimeSheet
2
- VERSION = "1.1.0"
2
+ VERSION = "1.2.0"
3
3
  end
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.1.0
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: 2025-09-17 00:00:00.000000000 Z
11
+ date: 2026-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: roo