time-sheet 0.5.2
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|