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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 461fe4226d6a5980fa168c7c918b01b35fb27aebef3110ff25d6d295709c6551
4
- data.tar.gz: 830f66bef87e5925b117e32a2e82647736997381a07efe12a8e8151123e7d9cd
3
+ metadata.gz: fe61ceab6f7a30eaeb9a0e54854776f8dc09b1ba55f4578e98937b9ad7669388
4
+ data.tar.gz: 3fed81fb509d8df3d81118364bad0e6deaec9163ad75ecd7adfe128e432534ae
5
5
  SHA512:
6
- metadata.gz: 6b99531029582021e5eff7c47a1483e24ad6ceb86b0865c2abd7b6af0496225b9e0f222c958538f073553e6ce5b4e4fe36d8e9ec10d3d2ab9b7ed8e1b83523f7
7
- data.tar.gz: 358e04d3c9f16416d451d192ed5ad2a94545ef135250ecb3836ce75d5f551b3295e65b1d44b3a36f9b71ab95386acd466e866e2c27457e61c8ed4b853d311ad5
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
@@ -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
- TimeSheet.options = options
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 default_location
89
- result = []
90
- config_file = "#{ENV['HOME']}/.time-sheet.conf"
91
- if File.exist?(config_file)
92
- File.read(config_file).split("\n").each do |line|
93
- if m = line.match(/^([a-z_]+):(.*)$/)
94
- result << m[2].strip if m[1] == 'location'
95
- end
96
- end
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
- result << "#{ENV['HOME']}/time-sheet" if result.empty?
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: default_location
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
- data = TimeSheet::Time.invoice(options)
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
- @data['activity'] ||= self.prev.activity
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(@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
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 duration <= 0
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
@@ -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
@@ -11,36 +11,55 @@ module TimeSheet::Time
11
11
  results = {
12
12
  'entries' => [],
13
13
  'total' => 0.0,
14
- 'projects' => {}
14
+ 'raw_total' => 0.0,
15
+ 'projects' => {},
16
+ 'tags' => {}
15
17
  }
16
18
 
17
- x = nil
18
- Parser.new(options[:location]).entries.each do |e|
19
- unless x
20
- x = true
21
- end
22
- if e.matches?(options)
23
- results['total'] += e.duration
24
- results['projects'][e.project] ||= {'total' => 0.0, 'activities' => {}}
25
- results['projects'][e.project]['total'] += e.duration
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
- Parser.new(options[:location]).entries.each do |e|
70
- if e.matches?(options)
71
- grouped[[e.date, e.description]] ||= 0
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
@@ -1,3 +1,3 @@
1
1
  module TimeSheet
2
- VERSION = "1.0.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.0.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-02-07 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
@@ -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.16
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