time-sheet 0.5.2
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 +7 -0
- data/.gitignore +12 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/Gemfile +6 -0
- data/README.md +19 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/time-sheet.rb +4 -0
- data/lib/time_sheet.rb +14 -0
- data/lib/time_sheet/table_printer.rb +65 -0
- data/lib/time_sheet/time.rb +134 -0
- data/lib/time_sheet/time/cmd.rb +202 -0
- data/lib/time_sheet/time/entry.rb +143 -0
- data/lib/time_sheet/time/exception.rb +3 -0
- data/lib/time_sheet/time/parser.rb +70 -0
- data/lib/time_sheet/time/util.rb +61 -0
- data/lib/time_sheet/version.rb +3 -0
- data/time_sheet.gemspec +29 -0
- metadata +148 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: c8452bc9323ceb47d374ee7c7654983c02c78e004e9f74dd35c8a51dac189432
|
4
|
+
data.tar.gz: 84f5adbec539706a2568be38fc02a8e9816c6cc1c57ef95561e551685a29ab4d
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 8a5f99c3e94ad248ed228710ea6a396821358de6246482b3e6e705818795423ef25e9f83efe2eb2a68f61caf80d10c4534a601664695227fe5cd63fc8b206942
|
7
|
+
data.tar.gz: 32b4e62a904ba3227cff54381d7c9face9b6d53f4a41cc463b16158d2cf35647ce48ed37d1f616db5916b8eff707c79fa18c016ffcb34b0747b8b9e20582621a
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# TimeSheet
|
2
|
+
|
3
|
+
This gem allows you to parse a spreadsheet with time tracking information to
|
4
|
+
generate various statistics suitable for invoices and project effort estimates.
|
5
|
+
|
6
|
+
## Installation
|
7
|
+
|
8
|
+
```bash
|
9
|
+
gem install time-sheet
|
10
|
+
```
|
11
|
+
|
12
|
+
## Usage
|
13
|
+
|
14
|
+
The gem bundles an executable which includes a help command, just run
|
15
|
+
`time-sheet.rb --help`
|
16
|
+
|
17
|
+
## Contributing
|
18
|
+
|
19
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/moritzschepp/time-sheet
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "mps"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/exe/time-sheet.rb
ADDED
data/lib/time_sheet.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'time_sheet/version'
|
2
|
+
|
3
|
+
if defined?(Bundler)
|
4
|
+
require 'pry'
|
5
|
+
end
|
6
|
+
|
7
|
+
module TimeSheet
|
8
|
+
autoload :Time, 'time_sheet/time'
|
9
|
+
autoload :TablePrinter, 'time_sheet/table_printer'
|
10
|
+
|
11
|
+
def self.root
|
12
|
+
@root ||= File.expand_path(File.dirname(__FILE__) + '/..')
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
class TimeSheet::TablePrinter
|
2
|
+
|
3
|
+
def initialize(data = [], options = {})
|
4
|
+
@options = options
|
5
|
+
@data = data
|
6
|
+
end
|
7
|
+
|
8
|
+
attr_reader :options
|
9
|
+
|
10
|
+
def <<(row)
|
11
|
+
@data << row
|
12
|
+
end
|
13
|
+
|
14
|
+
def flush
|
15
|
+
@widths = nil
|
16
|
+
end
|
17
|
+
|
18
|
+
def generate
|
19
|
+
flush
|
20
|
+
|
21
|
+
result = []
|
22
|
+
@data.each do |row|
|
23
|
+
output = if row == '-'
|
24
|
+
widths.map{|w| '-' * w}
|
25
|
+
else
|
26
|
+
row.each_with_index.map do |r, i|
|
27
|
+
format(r, widths[i], i == row.size - 1)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
result << output.join(options[:trim] ? '|' : ' | ')
|
31
|
+
end
|
32
|
+
result.join("\n")
|
33
|
+
end
|
34
|
+
|
35
|
+
def widths
|
36
|
+
@widths ||= @data.first.each_with_index.map do |c, i|
|
37
|
+
@data.map{|row| row == '-' ? 0 : size(row[i])}.max
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def format(value, width, last_column = false)
|
42
|
+
str = case value
|
43
|
+
when Integer then value.to_s.rjust(width)
|
44
|
+
when Date then value.strftime('%Y-%m-%d').rjust(width)
|
45
|
+
when Time then value.strftime('%H:%M').rjust(width)
|
46
|
+
when Float then ("%.2f" % value).rjust(width)
|
47
|
+
when nil then ' ' * width
|
48
|
+
else
|
49
|
+
last_column ? value : value.ljust(width)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def size(value)
|
54
|
+
case value
|
55
|
+
when nil then 0
|
56
|
+
when Integer then value.to_s.size
|
57
|
+
when Float then ("%.2f" % value).size
|
58
|
+
when Date then 10
|
59
|
+
when Time then 5
|
60
|
+
else
|
61
|
+
value.size
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
require 'erb'
|
2
|
+
|
3
|
+
module TimeSheet::Time
|
4
|
+
autoload :Cmd, 'time_sheet/time/cmd'
|
5
|
+
autoload :Entry, 'time_sheet/time/entry'
|
6
|
+
autoload :Exception, 'time_sheet/time/exception'
|
7
|
+
autoload :Parser, 'time_sheet/time/parser'
|
8
|
+
autoload :Util, 'time_sheet/time/util'
|
9
|
+
|
10
|
+
def self.report(options)
|
11
|
+
results = {
|
12
|
+
'entries' => [],
|
13
|
+
'total' => 0.0,
|
14
|
+
'projects' => {}
|
15
|
+
}
|
16
|
+
|
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
|
31
|
+
end
|
32
|
+
|
33
|
+
averages(results, options)
|
34
|
+
|
35
|
+
results
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.averages(results, options)
|
39
|
+
days = 1
|
40
|
+
|
41
|
+
unless results['entries'].empty?
|
42
|
+
time = (
|
43
|
+
(options[:to] || Util.day_end) -
|
44
|
+
(options[:from] || results['entries'].first[0].to_time)
|
45
|
+
).to_i
|
46
|
+
days = (time.to_f / 60 / 60 / 24).round
|
47
|
+
end
|
48
|
+
|
49
|
+
weeks = days / 7.0
|
50
|
+
months = days / 30.0
|
51
|
+
workdays = weeks * 5.0
|
52
|
+
worked = TimeSheet::Time::Util.hours(results['total'])
|
53
|
+
|
54
|
+
results['averages'] = {
|
55
|
+
'days' => days,
|
56
|
+
'weeks' => weeks,
|
57
|
+
'months' => months,
|
58
|
+
'workdays' => workdays,
|
59
|
+
'worked' => worked,
|
60
|
+
'hours_per_day' => worked / days,
|
61
|
+
'hours_per_workday' => worked / workdays,
|
62
|
+
'hours_per_week' => worked / weeks,
|
63
|
+
'hours_per_month' => worked / months,
|
64
|
+
}
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.invoice(options)
|
68
|
+
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
|
74
|
+
end
|
75
|
+
rows = []
|
76
|
+
grouped.each{|k, d| rows << [k.first, d, k.last]}
|
77
|
+
packages = [[]]
|
78
|
+
ptotal = 0
|
79
|
+
|
80
|
+
while row = rows.shift
|
81
|
+
if options[:package] > 0
|
82
|
+
new_package_size = ptotal + row[1]
|
83
|
+
|
84
|
+
if new_package_size > options[:package] * 60
|
85
|
+
filler = row.dup
|
86
|
+
filler[1] = options[:package] * 60 - ptotal
|
87
|
+
packages.last << filler
|
88
|
+
packages << []
|
89
|
+
ptotal = 0
|
90
|
+
|
91
|
+
# we re-queue the remainder of the current row so that it is picked
|
92
|
+
# up by the next iteration
|
93
|
+
row[1] -= filler[1]
|
94
|
+
rows.unshift row
|
95
|
+
next
|
96
|
+
end
|
97
|
+
|
98
|
+
if new_package_size == options[:package] * 60
|
99
|
+
packages.last << row
|
100
|
+
packages << []
|
101
|
+
ptotal = 0
|
102
|
+
next
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
packages.last << row
|
107
|
+
ptotal += row[1]
|
108
|
+
end
|
109
|
+
|
110
|
+
if packages.last.empty?
|
111
|
+
packages.pop
|
112
|
+
end
|
113
|
+
|
114
|
+
if options[:petty]
|
115
|
+
packages.map! do |package|
|
116
|
+
petty = 0
|
117
|
+
package.select! do |row|
|
118
|
+
if row[1] < options[:petty]
|
119
|
+
petty += row[1]
|
120
|
+
next false
|
121
|
+
end
|
122
|
+
true
|
123
|
+
end
|
124
|
+
if petty > 0
|
125
|
+
package << [nil, petty, 'misc']
|
126
|
+
end
|
127
|
+
package
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
packages
|
132
|
+
end
|
133
|
+
|
134
|
+
end
|
@@ -0,0 +1,202 @@
|
|
1
|
+
require 'slop'
|
2
|
+
require 'time'
|
3
|
+
|
4
|
+
class TimeSheet::Time::Cmd
|
5
|
+
def run
|
6
|
+
if d = options[:from]
|
7
|
+
if d.match(/^\d\d?-\d\d?$/)
|
8
|
+
d = "#{TimeSheet::Time::Util.now.year}-#{d}"
|
9
|
+
end
|
10
|
+
|
11
|
+
if d.match(/^\d{4}$/)
|
12
|
+
d = "#{d}-01-01"
|
13
|
+
end
|
14
|
+
|
15
|
+
options[:from] = Time.parse(d)
|
16
|
+
end
|
17
|
+
|
18
|
+
if d = options[:to]
|
19
|
+
if d.match(/^\d\d?-\d\d?$/)
|
20
|
+
d = "#{TimeSheet::Time::Util.now.year}-#{d}"
|
21
|
+
end
|
22
|
+
|
23
|
+
if d.match(/^\d{4}$/)
|
24
|
+
d = "#{d}-12-31"
|
25
|
+
end
|
26
|
+
|
27
|
+
options[:to] = Time.parse(d)
|
28
|
+
end
|
29
|
+
|
30
|
+
if options[:help]
|
31
|
+
puts options
|
32
|
+
elsif options[:version]
|
33
|
+
puts TimeSheet::VERSION
|
34
|
+
else
|
35
|
+
case command
|
36
|
+
when 'invoice'
|
37
|
+
invoice
|
38
|
+
when 'report', 'default'
|
39
|
+
report
|
40
|
+
when 'today', 't'
|
41
|
+
options[:from] = TimeSheet::Time::Util.today
|
42
|
+
options[:summary] = true
|
43
|
+
report
|
44
|
+
when 'yesterday', 'y'
|
45
|
+
options[:from] = TimeSheet::Time::Util.yesterday
|
46
|
+
options[:to] = TimeSheet::Time::Util.yesterday
|
47
|
+
options[:summary] = true
|
48
|
+
report
|
49
|
+
when 'week', 'w'
|
50
|
+
options[:from] = TimeSheet::Time::Util.week_start
|
51
|
+
options[:summary] = true
|
52
|
+
report
|
53
|
+
when 'last-week', 'lw'
|
54
|
+
options[:from] = TimeSheet::Time::Util.week_start(-1)
|
55
|
+
options[:to] = TimeSheet::Time::Util.week_end(-1)
|
56
|
+
options[:summary] = true
|
57
|
+
report
|
58
|
+
when 'month', 'm'
|
59
|
+
options[:from] = TimeSheet::Time::Util.month_start
|
60
|
+
options[:summary] = true
|
61
|
+
report
|
62
|
+
when 'last-month', 'lm'
|
63
|
+
options[:from] = TimeSheet::Time::Util.month_start(-1)
|
64
|
+
options[:to] = TimeSheet::Time::Util.month_end(-1)
|
65
|
+
options[:summary] = true
|
66
|
+
report
|
67
|
+
when 'overview'
|
68
|
+
overview
|
69
|
+
else
|
70
|
+
raise "unknown command: #{command}"
|
71
|
+
end
|
72
|
+
|
73
|
+
if options[:verbose]
|
74
|
+
puts "\noptions:"
|
75
|
+
p options.to_h
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def options
|
81
|
+
@options ||= Slop.parse do |o|
|
82
|
+
o.banner = [
|
83
|
+
"usage: time.rb [command] [options]\n",
|
84
|
+
'available commands:',
|
85
|
+
" report (default): list entries conforming to given criteria",
|
86
|
+
" invoice: compress similar entries and filter petty ones. Optionally package for e.g. monthly invoicing",
|
87
|
+
"\n general options:"
|
88
|
+
].join("\n")
|
89
|
+
|
90
|
+
o.boolean '-h', '--help', 'show help'
|
91
|
+
o.boolean '--version', 'show the version'
|
92
|
+
o.array('-l', '--location', 'a location to gather data from (file or directory)',
|
93
|
+
default: ["#{ENV['HOME']}/Desktop/cloud/time"]
|
94
|
+
)
|
95
|
+
o.string '-f', '--from', 'ignore entries older than the date given'
|
96
|
+
o.string '-t', '--to', 'ignore entries more recent than the date given'
|
97
|
+
o.string '-p', '--project', 'take only entries of this project into account'
|
98
|
+
o.string '-a', '--activity', 'take only entries of this activity into account'
|
99
|
+
o.string '-d', '--description', 'consider only entries matching this description'
|
100
|
+
o.float '-r', '--rate', 'use an alternative hourly rate (default: 80.0)', default: 80.00
|
101
|
+
o.boolean '-s', '--summary', 'when reporting, add summary section'
|
102
|
+
o.boolean '--trim', 'compact the output for processing as CSV', default: false
|
103
|
+
o.boolean '-v', '--verbose', 'be more verbose'
|
104
|
+
o.separator "\n invoice options:"
|
105
|
+
o.integer '--package', 'for invoice output: build packages of this duration in hours', default: 0
|
106
|
+
o.integer '--petty', 'fold records of a certain threshold into a "misc" activity', default: 0
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def command
|
111
|
+
options.arguments.shift || 'default'
|
112
|
+
end
|
113
|
+
|
114
|
+
def convert_to_time
|
115
|
+
if options[:from]
|
116
|
+
options[:from] = options[:from].to_time
|
117
|
+
end
|
118
|
+
if options[:to].is_a?(Date)
|
119
|
+
options[:to] = options[:to].to_time + 24 * 60 * 60
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def invoice
|
124
|
+
convert_to_time
|
125
|
+
|
126
|
+
data = TimeSheet::Time.invoice(options)
|
127
|
+
|
128
|
+
data.each do |package|
|
129
|
+
tp = TimeSheet::TablePrinter.new package, options
|
130
|
+
puts tp.generate
|
131
|
+
puts "\n"
|
132
|
+
end
|
133
|
+
|
134
|
+
if options[:package]
|
135
|
+
package = data.last.map{|entry| entry[1]}.sum
|
136
|
+
total = options[:package] * 60
|
137
|
+
percent = (package / total.to_f * 100)
|
138
|
+
|
139
|
+
puts "last package duration: #{package}/#{total} (#{percent.round 2}%)"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def report
|
144
|
+
convert_to_time
|
145
|
+
|
146
|
+
data = TimeSheet::Time.report(options)
|
147
|
+
tp = TimeSheet::TablePrinter.new data['entries'], options
|
148
|
+
puts tp.generate
|
149
|
+
|
150
|
+
|
151
|
+
if options[:summary]
|
152
|
+
puts "\nSummary:"
|
153
|
+
|
154
|
+
tdata = [['project', 'activity', 'time [m]', 'time [h]', 'price [€]']]
|
155
|
+
tdata << [
|
156
|
+
'all',
|
157
|
+
'',
|
158
|
+
TimeSheet::Time::Util.minutes(data['total']),
|
159
|
+
TimeSheet::Time::Util.hours(data['total']),
|
160
|
+
TimeSheet::Time::Util.price(data['total'], options[:rate])
|
161
|
+
]
|
162
|
+
|
163
|
+
data['projects'].sort_by{|k, v| v['total']}.reverse.to_h.each do |pname, pdata|
|
164
|
+
previous = nil
|
165
|
+
|
166
|
+
tdata << '-'
|
167
|
+
tdata << [
|
168
|
+
pname,
|
169
|
+
'all',
|
170
|
+
TimeSheet::Time::Util.minutes(pdata['total']),
|
171
|
+
TimeSheet::Time::Util.hours(pdata['total']),
|
172
|
+
TimeSheet::Time::Util.price(pdata['total'], options[:rate])
|
173
|
+
]
|
174
|
+
|
175
|
+
pdata['activities'].sort_by{|k, v| v}.reverse.to_h.each do |aname, atotal|
|
176
|
+
tdata << [
|
177
|
+
'',
|
178
|
+
aname,
|
179
|
+
TimeSheet::Time::Util.minutes(atotal),
|
180
|
+
TimeSheet::Time::Util.hours(atotal),
|
181
|
+
TimeSheet::Time::Util.price(atotal, options[:rate])
|
182
|
+
]
|
183
|
+
previous = pname
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
tdata << '-'
|
188
|
+
|
189
|
+
tp = TimeSheet::TablePrinter.new tdata, options
|
190
|
+
puts tp.generate
|
191
|
+
|
192
|
+
puts [
|
193
|
+
"days: #{data['averages']['days']}",
|
194
|
+
"worked: h/day: #{data['averages']['hours_per_day'].round(2)}",
|
195
|
+
"h/workday: #{data['averages']['hours_per_workday'].round(2)}",
|
196
|
+
"h/week: #{data['averages']['hours_per_week'].round(2)}",
|
197
|
+
"h/month(30 days): #{data['averages']['hours_per_month'].round(2)}"
|
198
|
+
].join(', ')
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
class TimeSheet::Time::Entry
|
2
|
+
def self.now
|
3
|
+
@now ||= Time.now
|
4
|
+
end
|
5
|
+
|
6
|
+
def initialize(data)
|
7
|
+
@data = data
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_accessor :prev, :next
|
11
|
+
|
12
|
+
def project
|
13
|
+
@data['project'] ||= self.prev.project
|
14
|
+
end
|
15
|
+
|
16
|
+
def activity
|
17
|
+
@data['activity'] ||= self.prev.activity
|
18
|
+
end
|
19
|
+
|
20
|
+
def description
|
21
|
+
@data['description'] ||= self.prev.description
|
22
|
+
end
|
23
|
+
|
24
|
+
def date
|
25
|
+
@date ||= @data['date'] || self.prev.date
|
26
|
+
end
|
27
|
+
|
28
|
+
def start
|
29
|
+
@start ||= Time.mktime(
|
30
|
+
date.year, date.month, date.day,
|
31
|
+
@data['start'].hour, @data['start'].min
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
def end
|
37
|
+
ends_at = @data['end'] || (self.next ? self.next.start : self.class.now)
|
38
|
+
|
39
|
+
@end ||= Time.mktime(
|
40
|
+
date.year, date.month, date.day,
|
41
|
+
ends_at.hour, ends_at.min
|
42
|
+
)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Experiment to add timezone support. However, this would complicate every day
|
46
|
+
# handing because of daylight saving time changes.
|
47
|
+
# def start_zone
|
48
|
+
# @start_zone ||= if v = @data['start_zone']
|
49
|
+
# # allow a name prefixing the value
|
50
|
+
# v.split(/\s/).last
|
51
|
+
# elsif v = self.prev.start_zone
|
52
|
+
# v
|
53
|
+
# else
|
54
|
+
# self.class.now.getlocal.utc_offset
|
55
|
+
# # use this process' timezone
|
56
|
+
# nil
|
57
|
+
# end
|
58
|
+
# end
|
59
|
+
|
60
|
+
# def end_zone
|
61
|
+
# @end_zone ||= if v = @data['end_zone']
|
62
|
+
# # allow a name prefixing the value
|
63
|
+
# v.split(/\s/).last
|
64
|
+
# elsif self.prev && v = self.prev.end_zone
|
65
|
+
# v
|
66
|
+
# else
|
67
|
+
# # self.class.now.getlocal.utc_offset
|
68
|
+
# # use this process' timezone
|
69
|
+
# nil
|
70
|
+
# end
|
71
|
+
# end
|
72
|
+
|
73
|
+
def duration
|
74
|
+
(self.end - self.start) / 60
|
75
|
+
end
|
76
|
+
|
77
|
+
def tags
|
78
|
+
(@data['tags'] || '').split(/\s*,\s*/)
|
79
|
+
end
|
80
|
+
|
81
|
+
def working_day?
|
82
|
+
!date.saturday? && !date.sunday? && !tags.include?('holiday')
|
83
|
+
end
|
84
|
+
|
85
|
+
def matches?(filters)
|
86
|
+
from = (filters[:from] ? filters[:from] : nil)
|
87
|
+
from = from.to_time if from.is_a?(Date)
|
88
|
+
to = (filters[:to] ? filters[:to] : nil)
|
89
|
+
to = (to + 1).to_time if to.is_a?(Date)
|
90
|
+
|
91
|
+
self.class.attrib_matches_any?(description, filters[:description]) &&
|
92
|
+
self.class.attrib_matches_any?(project, filters[:project]) &&
|
93
|
+
self.class.attrib_matches_any?(activity, filters[:activity]) &&
|
94
|
+
(!from || from <= self.start) &&
|
95
|
+
(!to || to >= self.end)
|
96
|
+
end
|
97
|
+
|
98
|
+
def valid?
|
99
|
+
(duration > 0) &&
|
100
|
+
((self.start < self.end) || !self.next)
|
101
|
+
end
|
102
|
+
|
103
|
+
def to_row
|
104
|
+
[date, start, self.end, duration.to_i, project, activity, description]
|
105
|
+
end
|
106
|
+
|
107
|
+
def to_s
|
108
|
+
values = [
|
109
|
+
date.strftime('%Y-%m-%d'),
|
110
|
+
start.strftime('%H:%M'),
|
111
|
+
self.end.strftime('%H:%M'),
|
112
|
+
duration.to_i.to_s.rjust(4),
|
113
|
+
project,
|
114
|
+
activity,
|
115
|
+
description
|
116
|
+
].join(' | ')
|
117
|
+
end
|
118
|
+
|
119
|
+
def to_hash
|
120
|
+
return {
|
121
|
+
'date' => date,
|
122
|
+
'start' => start,
|
123
|
+
'end' => self.end,
|
124
|
+
'duration' => duration,
|
125
|
+
'project' => project,
|
126
|
+
'activity' => activity,
|
127
|
+
'description' => description
|
128
|
+
}
|
129
|
+
end
|
130
|
+
|
131
|
+
def <=>(other)
|
132
|
+
(self.date <=> other.date) || self.start <=> other.start
|
133
|
+
end
|
134
|
+
|
135
|
+
def self.attrib_matches_any?(value, patterns)
|
136
|
+
return true if !patterns
|
137
|
+
|
138
|
+
patterns.split(/\s*,\s*/).any? do |pattern|
|
139
|
+
value.match(pattern)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'spreadsheet'
|
2
|
+
|
3
|
+
class TimeSheet::Time::Parser
|
4
|
+
|
5
|
+
def initialize(dirs)
|
6
|
+
@dirs = dirs
|
7
|
+
end
|
8
|
+
|
9
|
+
def files
|
10
|
+
results = []
|
11
|
+
@dirs.each do |dir|
|
12
|
+
if File.directory?(dir)
|
13
|
+
results += Dir["#{dir}/**/*.xls"]
|
14
|
+
else
|
15
|
+
results << dir
|
16
|
+
end
|
17
|
+
end
|
18
|
+
results.sort
|
19
|
+
end
|
20
|
+
|
21
|
+
def entries
|
22
|
+
@entries ||= begin
|
23
|
+
results = []
|
24
|
+
hashes_per_file.each do |hashes|
|
25
|
+
file_results = []
|
26
|
+
hashes.each do |e|
|
27
|
+
te = TimeSheet::Time::Entry.new(e)
|
28
|
+
if file_results.last
|
29
|
+
file_results.last.next = te
|
30
|
+
te.prev = file_results.last
|
31
|
+
end
|
32
|
+
file_results << te
|
33
|
+
end
|
34
|
+
results += file_results
|
35
|
+
end
|
36
|
+
results.sort!
|
37
|
+
results.each do |r|
|
38
|
+
unless r.valid?
|
39
|
+
# byebug
|
40
|
+
raise TimeSheet::Time::Exception.new("invalid time entry: #{r.to_row.inspect}")
|
41
|
+
end
|
42
|
+
end
|
43
|
+
results
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def hashes_per_file
|
48
|
+
@hashes_per_file ||= begin
|
49
|
+
files.map do |f|
|
50
|
+
results = []
|
51
|
+
Spreadsheet.open(f).worksheets.each do |sheet|
|
52
|
+
headers = sheet.rows.first.to_a
|
53
|
+
sheet.rows[1..-1].each do |row|
|
54
|
+
# TODO find a way to guard against xls sheets with 65535 (empty)
|
55
|
+
# lines, perhaps:
|
56
|
+
# break if row[1].nil?
|
57
|
+
|
58
|
+
record = {}
|
59
|
+
row.each_with_index do |value, i|
|
60
|
+
record[headers[i]] = value
|
61
|
+
end
|
62
|
+
results << record
|
63
|
+
end
|
64
|
+
end
|
65
|
+
results
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module TimeSheet::Time::Util
|
2
|
+
|
3
|
+
def self.year_start(factor = 0)
|
4
|
+
Date.new(Date.today.year + factor, 1, 1)
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.year_end(factor = 0)
|
8
|
+
Date.new(Date.today.year + factor, 12, 31)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.month_start(factor = 0)
|
12
|
+
tmp = Date.today.prev_month(factor * -1)
|
13
|
+
Date.new tmp.year, tmp.month, 1
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.month_end(factor = 0)
|
17
|
+
tmp = (month_start(factor) + 45)
|
18
|
+
Date.new(tmp.year, tmp.month) - 1
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.week_start(factor = 0)
|
22
|
+
today - (today.wday - 1) % 7 + (factor * 7)
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.week_end(factor = 0)
|
26
|
+
week_start(factor) + 6
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.day_start
|
30
|
+
now.to_date.to_time
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.day_end
|
34
|
+
day_start + 60 * 60 * 24 - 1
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.now
|
38
|
+
Time.now
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.today
|
42
|
+
now.to_date
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.yesterday
|
46
|
+
now.to_date - 1
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.minutes(duration)
|
50
|
+
duration.to_i
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.hours(duration)
|
54
|
+
(duration / 60.0).round(2)
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.price(duration, rate)
|
58
|
+
(self.hours(duration) * rate).round(2)
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
data/time_sheet.gemspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "time_sheet/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "time-sheet"
|
8
|
+
spec.version = TimeSheet::VERSION
|
9
|
+
spec.authors = ["Moritz Schepp"]
|
10
|
+
spec.email = ["moritz.schepp@gmail.com"]
|
11
|
+
spec.license = 'GPL-3.0-only'
|
12
|
+
|
13
|
+
spec.summary = "a time tracking solution based on spreadsheets"
|
14
|
+
|
15
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
16
|
+
f.match(%r{^(test|spec|features)/})
|
17
|
+
end
|
18
|
+
spec.bindir = "exe"
|
19
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
20
|
+
spec.require_paths = ["lib"]
|
21
|
+
|
22
|
+
spec.add_dependency 'spreadsheet'
|
23
|
+
spec.add_dependency 'slop'
|
24
|
+
|
25
|
+
spec.add_development_dependency "bundler", "~> 1.15"
|
26
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
27
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
28
|
+
spec.add_development_dependency 'pry'
|
29
|
+
end
|
metadata
ADDED
@@ -0,0 +1,148 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: time-sheet
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.5.2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Moritz Schepp
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2019-01-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: spreadsheet
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: slop
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: bundler
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.15'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.15'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rake
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '10.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '10.0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '3.0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3.0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: pry
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
description:
|
98
|
+
email:
|
99
|
+
- moritz.schepp@gmail.com
|
100
|
+
executables:
|
101
|
+
- time-sheet.rb
|
102
|
+
extensions: []
|
103
|
+
extra_rdoc_files: []
|
104
|
+
files:
|
105
|
+
- ".gitignore"
|
106
|
+
- ".rspec"
|
107
|
+
- ".travis.yml"
|
108
|
+
- Gemfile
|
109
|
+
- README.md
|
110
|
+
- Rakefile
|
111
|
+
- bin/console
|
112
|
+
- bin/setup
|
113
|
+
- exe/time-sheet.rb
|
114
|
+
- lib/time_sheet.rb
|
115
|
+
- lib/time_sheet/table_printer.rb
|
116
|
+
- lib/time_sheet/time.rb
|
117
|
+
- lib/time_sheet/time/cmd.rb
|
118
|
+
- lib/time_sheet/time/entry.rb
|
119
|
+
- lib/time_sheet/time/exception.rb
|
120
|
+
- lib/time_sheet/time/parser.rb
|
121
|
+
- lib/time_sheet/time/util.rb
|
122
|
+
- lib/time_sheet/version.rb
|
123
|
+
- time_sheet.gemspec
|
124
|
+
homepage:
|
125
|
+
licenses:
|
126
|
+
- GPL-3.0-only
|
127
|
+
metadata: {}
|
128
|
+
post_install_message:
|
129
|
+
rdoc_options: []
|
130
|
+
require_paths:
|
131
|
+
- lib
|
132
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
133
|
+
requirements:
|
134
|
+
- - ">="
|
135
|
+
- !ruby/object:Gem::Version
|
136
|
+
version: '0'
|
137
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
138
|
+
requirements:
|
139
|
+
- - ">="
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '0'
|
142
|
+
requirements: []
|
143
|
+
rubyforge_project:
|
144
|
+
rubygems_version: 2.7.6
|
145
|
+
signing_key:
|
146
|
+
specification_version: 4
|
147
|
+
summary: a time tracking solution based on spreadsheets
|
148
|
+
test_files: []
|