timesheet 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +4 -0
- data/Manifest.txt +24 -0
- data/README.rdoc +104 -0
- data/Rakefile +29 -0
- data/bin/timesheet +7 -0
- data/lib/timesheet.rb +82 -0
- data/lib/timesheet/range_extensions.rb +24 -0
- data/lib/timesheet/time_entry.rb +50 -0
- data/lib/timesheet/time_log.rb +71 -0
- data/lib/timesheet/time_report.rb +113 -0
- data/lib/timesheet/timesheet_parser.rb +242 -0
- data/lib/timesheet/trollop.rb +761 -0
- data/script/console +10 -0
- data/script/destroy +14 -0
- data/script/generate +14 -0
- data/spec/range_extensions_spec.rb +17 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +17 -0
- data/spec/time_entry_spec.rb +137 -0
- data/spec/time_log_spec.rb +164 -0
- data/spec/time_report_spec.rb +104 -0
- data/spec/timesheet_parser_spec.rb +235 -0
- data/spec/timesheet_spec.rb +188 -0
- data/tasks/rspec.rake +21 -0
- metadata +274 -0
data/History.txt
ADDED
data/Manifest.txt
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
History.txt
|
2
|
+
Manifest.txt
|
3
|
+
README.rdoc
|
4
|
+
Rakefile
|
5
|
+
bin/timesheet
|
6
|
+
lib/timesheet.rb
|
7
|
+
lib/timesheet/range_extensions.rb
|
8
|
+
lib/timesheet/time_entry.rb
|
9
|
+
lib/timesheet/time_log.rb
|
10
|
+
lib/timesheet/time_report.rb
|
11
|
+
lib/timesheet/timesheet_parser.rb
|
12
|
+
lib/timesheet/trollop.rb
|
13
|
+
script/console
|
14
|
+
script/destroy
|
15
|
+
script/generate
|
16
|
+
spec/range_extensions_spec.rb
|
17
|
+
spec/spec.opts
|
18
|
+
spec/spec_helper.rb
|
19
|
+
spec/time_entry_spec.rb
|
20
|
+
spec/time_log_spec.rb
|
21
|
+
spec/time_report_spec.rb
|
22
|
+
spec/timesheet_parser_spec.rb
|
23
|
+
spec/timesheet_spec.rb
|
24
|
+
tasks/rspec.rake
|
data/README.rdoc
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
= timesheet
|
2
|
+
|
3
|
+
* http://github.com/jschank/timesheet
|
4
|
+
|
5
|
+
== DESCRIPTION:
|
6
|
+
|
7
|
+
<i>Timesheet</i> is simple ruby application for tracking time spent on projects.
|
8
|
+
It is a console application that uses a simple text file storage back-end (YAML::Store)
|
9
|
+
|
10
|
+
The main idea is to be able to produce reports of hours spent, such that I can use geektool to display the reports.
|
11
|
+
|
12
|
+
== FEATURES/PROBLEMS:
|
13
|
+
|
14
|
+
with <i>timesheet</i> you can:
|
15
|
+
|
16
|
+
* <b>add</b> new entries to the database
|
17
|
+
* <b>edit</b> existing entries
|
18
|
+
* <b>delete</b> existing entries
|
19
|
+
* <b>list</b> existing entries, and specify a time range
|
20
|
+
* <b>produce</b> simple reports
|
21
|
+
|
22
|
+
== SYNOPSIS:
|
23
|
+
|
24
|
+
<i>Timesheet</i> is a script for keeping track of time spent on various projects.
|
25
|
+
|
26
|
+
=== Usage:
|
27
|
+
|
28
|
+
timesheet [OPTIONS] COMMAND [ARGS]
|
29
|
+
|
30
|
+
COMMAND is any of the following:
|
31
|
+
add
|
32
|
+
edit
|
33
|
+
delete
|
34
|
+
list
|
35
|
+
report
|
36
|
+
|
37
|
+
OPTIONS are:
|
38
|
+
<tt>--debug, -d</tt>:: Show debugging information while processing
|
39
|
+
<tt>--version, -v</tt>:: Print version and exit
|
40
|
+
<tt>--help, -h</tt>:: Show this message
|
41
|
+
|
42
|
+
=== For help run:
|
43
|
+
|
44
|
+
timesheet COMMAND --help
|
45
|
+
|
46
|
+
for more information on a specific command.
|
47
|
+
|
48
|
+
=== Your Data
|
49
|
+
|
50
|
+
your <i>timesheet</i> data will be stored in a hidden directory under your user account. <i>Timesheet</i> figures out
|
51
|
+
where this is by referencing the "HOME" environment variable. The default location is therefore:
|
52
|
+
<tt>/Users/someuser/.timesheet/store.yaml</tt>
|
53
|
+
|
54
|
+
You may override this location to any specific location you want by setting the "TIMESHEET_DATA_FILE" environment variable.
|
55
|
+
This should be the full path to where you want the data stored. Including filename and extension.
|
56
|
+
You only need to set this if you are unsatisfied with the default location.
|
57
|
+
|
58
|
+
=== the .idea folder
|
59
|
+
|
60
|
+
This project was created using RubyMine 2.0, and that IDE stores its state in the .idea folder.
|
61
|
+
If you aren't using RubyMine, you can delete it.
|
62
|
+
|
63
|
+
== REQUIREMENTS:
|
64
|
+
|
65
|
+
* FIX (list of requirements)
|
66
|
+
|
67
|
+
== INSTALL:
|
68
|
+
|
69
|
+
sudo gem install timesheet
|
70
|
+
|
71
|
+
== To Do
|
72
|
+
|
73
|
+
- Add indicator in reports for start or end times which had to be trimmed
|
74
|
+
- Add indicator in reports that exclude comments, that comments are available, when they are, i.e. after a summary,
|
75
|
+
you might want to know that you should do a detail, or byday report
|
76
|
+
- Add option to produce pdf file, for this to work, I need to ensure that all the reports are using <b>ruport</b>.
|
77
|
+
- If comments get too long, we may want to exclude them from the detail report
|
78
|
+
- Add a new command to inspect a single entry. Just dump all the fields for a given record number
|
79
|
+
- Might want to track comments separately and/or make an entries comments an array rather than a single comment.
|
80
|
+
|
81
|
+
== LICENSE:
|
82
|
+
|
83
|
+
(The MIT License)
|
84
|
+
|
85
|
+
Copyright (c) 2009 John F. Schank III
|
86
|
+
|
87
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
88
|
+
a copy of this software and associated documentation files (the
|
89
|
+
'Software'), to deal in the Software without restriction, including
|
90
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
91
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
92
|
+
permit persons to whom the Software is furnished to do so, subject to
|
93
|
+
the following conditions:
|
94
|
+
|
95
|
+
The above copyright notice and this permission notice shall be
|
96
|
+
included in all copies or substantial portions of the Software.
|
97
|
+
|
98
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
99
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
100
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
101
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
102
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
103
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
104
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/Rakefile
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
gem 'hoe', '>= 2.1.0'
|
3
|
+
require 'hoe'
|
4
|
+
require 'fileutils'
|
5
|
+
require './lib/timesheet'
|
6
|
+
|
7
|
+
Hoe::plugin :newgem
|
8
|
+
Hoe::plugin :gemcutter
|
9
|
+
# Hoe.plugin :website
|
10
|
+
# Hoe.plugin :cucumberfeatures
|
11
|
+
|
12
|
+
# Generate all the Rake tasks
|
13
|
+
# Run 'rake -T' to see list of generated tasks (from gem root directory)
|
14
|
+
$hoe = Hoe.spec 'timesheet' do
|
15
|
+
self.developer 'John F. Schank III', 'jschank@mac.com'
|
16
|
+
self.rubyforge_name = self.name # TODO this is default value
|
17
|
+
self.extra_deps = [['activesupport', '>= 0'], ['archive-tar-minitar', '>= 0'], ['chronic', '>= 0'], ['color', '>= 0'], ['fastercsv', '>= 0'], ['hoe', '>= 0'], ['json_pure', '>= 0'], ['newgem', '>= 0'], ['pdf-writer', '>= 0'], ['rake', '>= 0'], ['RedCloth', '>= 0'], ['rich_units', '>= 0'], ['rspec', '>= 0'], ['rubigen', '>= 0'], ['rubyforge', '>= 0'], ['ruport', '>= 0'], ['syntax', '>= 0'], ['transaction-simple', '>= 0']]
|
18
|
+
# self.extra_deps = [['activesupport','>= 2.0.2']]
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
require 'newgem/tasks'
|
23
|
+
Dir['tasks/**/*.rake'].each { |t| load t }
|
24
|
+
|
25
|
+
# TODO - want other tests/tasks run by default? Add them to the list
|
26
|
+
# remove_task :default
|
27
|
+
# task :default => [:spec, :features]
|
28
|
+
|
29
|
+
|
data/bin/timesheet
ADDED
data/lib/timesheet.rb
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
$:.unshift(File.dirname(__FILE__)) unless
|
2
|
+
$:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
|
3
|
+
|
4
|
+
require 'rubygems'
|
5
|
+
require 'fileutils'
|
6
|
+
require 'rich_units'
|
7
|
+
require 'pathname'
|
8
|
+
require 'yaml'
|
9
|
+
require 'yaml/store'
|
10
|
+
require 'timesheet/range_extensions'
|
11
|
+
require 'timesheet/time_entry'
|
12
|
+
require 'timesheet/time_log'
|
13
|
+
require 'timesheet/time_report'
|
14
|
+
require 'timesheet/trollop'
|
15
|
+
require 'timesheet/timesheet_parser'
|
16
|
+
|
17
|
+
class Timesheet
|
18
|
+
|
19
|
+
VERSION = '0.2.0'
|
20
|
+
|
21
|
+
def self.run(params)
|
22
|
+
command_hash = {}
|
23
|
+
command_hash = TimesheetParser.parse(params)
|
24
|
+
|
25
|
+
raise "Cannot determine location for data. Try timesheet --help for details." unless (ENV["TIMESHEET_DATA_FILE"] || ENV["HOME"])
|
26
|
+
|
27
|
+
data_file ||= ENV["TIMESHEET_DATA_FILE"]
|
28
|
+
data_file ||= (ENV["HOME"] + "/.timesheet/store.yaml")
|
29
|
+
|
30
|
+
FileUtils.makedirs(File.dirname(data_file))
|
31
|
+
|
32
|
+
store = YAML::Store.new(data_file)
|
33
|
+
tl = TimeLog.new(store)
|
34
|
+
ts = Timesheet.new(tl)
|
35
|
+
ts.process(command_hash)
|
36
|
+
|
37
|
+
rescue Exception => e
|
38
|
+
raise if command_hash[:debug]
|
39
|
+
puts e.message
|
40
|
+
|
41
|
+
end
|
42
|
+
|
43
|
+
def initialize(timelog)
|
44
|
+
@timelog = timelog
|
45
|
+
end
|
46
|
+
|
47
|
+
def process(command_opts)
|
48
|
+
command = command_opts[:command]
|
49
|
+
case command
|
50
|
+
when :add : process_add_command(command_opts)
|
51
|
+
when :edit : process_edit_command(command_opts)
|
52
|
+
when :delete : process_delete_command(command_opts)
|
53
|
+
when :report : process_report_command(command_opts)
|
54
|
+
else raise "Unknown command #{command}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def process_add_command(command_opts)
|
59
|
+
te = TimeEntry.new(command_opts[:project], command_opts[:start], command_opts[:end], command_opts[:comment])
|
60
|
+
@timelog.add(te)
|
61
|
+
end
|
62
|
+
|
63
|
+
def process_edit_command(command_opts)
|
64
|
+
record_number = command_opts.delete(:record_number)
|
65
|
+
@timelog.update(record_number, command_opts)
|
66
|
+
end
|
67
|
+
|
68
|
+
def process_delete_command(command_opts)
|
69
|
+
record_number = command_opts.delete(:record_number)
|
70
|
+
@timelog.delete(record_number)
|
71
|
+
end
|
72
|
+
|
73
|
+
def process_report_command(command_opts)
|
74
|
+
start_time = command_opts[:start]
|
75
|
+
end_time = command_opts[:end]
|
76
|
+
entries = @timelog.extract_entries(start_time, end_time)
|
77
|
+
time_report = TimeReport.new(entries)
|
78
|
+
time_report.report(command_opts)
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# Thanks to: http://opensoul.org/2007/2/13/ranges-include-or-overlap-with-ranges
|
2
|
+
class Range
|
3
|
+
|
4
|
+
|
5
|
+
def overlap?(range)
|
6
|
+
self.include?(range.first) || range.include?(self.first)
|
7
|
+
end
|
8
|
+
|
9
|
+
alias_method :include_without_range?, :include?
|
10
|
+
|
11
|
+
def include_with_range?(value)
|
12
|
+
if value.is_a?(Range)
|
13
|
+
last = value.exclude_end? ? value.last - 1 : value.last
|
14
|
+
self.include?(value.first) && self.include?(last)
|
15
|
+
else
|
16
|
+
include_without_range?(value)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
alias_method :include?, :include_with_range?
|
21
|
+
|
22
|
+
# alias_method_chain :include?, :range
|
23
|
+
|
24
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
class TimeEntry
|
2
|
+
|
3
|
+
include Comparable
|
4
|
+
|
5
|
+
def initialize(project, start_time, end_time, comment = nil)
|
6
|
+
|
7
|
+
raise ArgumentError.new("Start time must come before end time") if start_time > end_time
|
8
|
+
|
9
|
+
@project = project
|
10
|
+
@start_time = start_time
|
11
|
+
@end_time = end_time
|
12
|
+
@comment = comment
|
13
|
+
@record_number = nil
|
14
|
+
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_accessor :project
|
18
|
+
attr_reader :start_time
|
19
|
+
attr_reader :end_time
|
20
|
+
attr_accessor :comment
|
21
|
+
attr_accessor :record_number
|
22
|
+
|
23
|
+
def conflict?(other_entry)
|
24
|
+
to_range.overlap? other_entry.to_range
|
25
|
+
end
|
26
|
+
|
27
|
+
def start_time=(new_start_time)
|
28
|
+
raise ArgumentError.new("Start time must come before end time") if new_start_time > @end_time
|
29
|
+
@start_time = new_start_time
|
30
|
+
end
|
31
|
+
|
32
|
+
def end_time=(new_end_time)
|
33
|
+
raise ArgumentError.new("End time must come after start time") if @start_time > new_end_time
|
34
|
+
@end_time = new_end_time
|
35
|
+
end
|
36
|
+
|
37
|
+
def duration
|
38
|
+
return 0 if @start_time == nil || @end_time == nil
|
39
|
+
RichUnits::Duration.new(@end_time - @start_time)
|
40
|
+
end
|
41
|
+
|
42
|
+
def <=>(other_time_entry)
|
43
|
+
self.start_time <=> other_time_entry.start_time
|
44
|
+
end
|
45
|
+
|
46
|
+
def to_range
|
47
|
+
Range.new(@start_time, @end_time, true)
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
class TimeLog
|
2
|
+
|
3
|
+
def initialize(store)
|
4
|
+
@store = store
|
5
|
+
@store.transaction do
|
6
|
+
@store[:entries] ||= []
|
7
|
+
@store[:entry_record_number] ||= 0
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def count
|
12
|
+
@store.transaction do
|
13
|
+
@store[:entries].count
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def add(entry)
|
18
|
+
@store.transaction do
|
19
|
+
raise ArgumentError.new("Cannot add an entry which conflicts with existing entries.") if @store[:entries].any? { |e| e.conflict? entry }
|
20
|
+
@store[:entry_record_number] += 1
|
21
|
+
entry.record_number = @store[:entry_record_number]
|
22
|
+
@store[:entries] << entry
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def find(record_number)
|
27
|
+
@store.transaction do
|
28
|
+
found_entries = @store[:entries].select{|e| e.record_number == record_number}
|
29
|
+
raise "Record number #{record_number} is not unique in the database." if found_entries.count > 1
|
30
|
+
raise ArgumentError.new("Cannot find an entry with record number #{record_number} to edit") if found_entries.count == 0
|
31
|
+
|
32
|
+
found_entries.first
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def extract_entries(start_time, end_time)
|
37
|
+
covered_entries = []
|
38
|
+
@store.transaction do
|
39
|
+
span_to_cover = Range.new(start_time, end_time, true)
|
40
|
+
covered_entries = @store[:entries].select{|e| span_to_cover.overlap? e.to_range }
|
41
|
+
end
|
42
|
+
covered_entries
|
43
|
+
end
|
44
|
+
|
45
|
+
def update(record_number, properties)
|
46
|
+
@store.transaction do
|
47
|
+
found_entries = @store[:entries].select{|e| e.record_number == record_number}
|
48
|
+
raise "Record number #{record_number} is not unique in the database." if found_entries.count > 1
|
49
|
+
raise ArgumentError.new("Cannot find an entry with record number #{record_number} to edit") if found_entries.count == 0
|
50
|
+
|
51
|
+
entry = found_entries.first
|
52
|
+
entry.project = properties[:project] if properties[:project]
|
53
|
+
entry.start_time = properties[:start] if properties[:start]
|
54
|
+
entry.end_time = properties[:end] if properties[:end]
|
55
|
+
entry.comment = properties[:comment] if properties[:comment]
|
56
|
+
raise ArgumentError.new("The new times for this entry conflict with existing entries.") if @store[:entries].any? { |e| e != entry && e.conflict?(entry) }
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def delete(record_number)
|
61
|
+
@store.transaction do
|
62
|
+
found_entries = @store[:entries].select{|e| e.record_number == record_number}
|
63
|
+
raise "Record number #{record_number} is not unique in the database." if found_entries.count > 1
|
64
|
+
raise ArgumentError.new("Cannot find an entry with record number #{record_number} to delete.") if found_entries.count == 0
|
65
|
+
|
66
|
+
@store[:entries].delete(found_entries.first)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
|
71
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
require 'ruport'
|
2
|
+
require 'time'
|
3
|
+
|
4
|
+
class TimeReport
|
5
|
+
|
6
|
+
def initialize(entries)
|
7
|
+
@entries = entries
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_accessor :entries
|
11
|
+
|
12
|
+
def trim(start_time, end_time)
|
13
|
+
@entries.map! do |e|
|
14
|
+
e.start_time = start_time if e.to_range.include?(start_time)
|
15
|
+
e.end_time = end_time if e.to_range.include?(end_time)
|
16
|
+
e
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def report(command_options, outstream = STDOUT)
|
21
|
+
trim(command_options[:start], command_options[:end])
|
22
|
+
|
23
|
+
detail_report(outstream) if command_options[:detail]
|
24
|
+
summary_report(outstream) if command_options[:summary]
|
25
|
+
byday_report(outstream) if command_options[:byday]
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def detail_report(outstream)
|
31
|
+
table = Ruport::Data::Table({ :column_names => ["Id", "Project", "Start - Stop", "Hours", "Comment"]}) do |table|
|
32
|
+
@entries.sort.each do |entry|
|
33
|
+
row = []
|
34
|
+
|
35
|
+
# format record number
|
36
|
+
row << sprintf("%5d", entry.record_number)
|
37
|
+
|
38
|
+
# format project
|
39
|
+
row << entry.project
|
40
|
+
|
41
|
+
# format start - stop
|
42
|
+
str = ""
|
43
|
+
str << entry.start_time.strftime("%m/%d/%Y at %I:%M %p")
|
44
|
+
str << " to "
|
45
|
+
str << entry.end_time.strftime("%m/%d/%Y at ") if ( (entry.start_time.year != entry.end_time.year) && (entry.start_time.yday != entry.end_time.yday))
|
46
|
+
str << entry.end_time.strftime("%I:%M %p")
|
47
|
+
row << str
|
48
|
+
|
49
|
+
# format duration
|
50
|
+
str = ""
|
51
|
+
str << entry.duration.strftime("%d Days ") if entry.duration.days > 0
|
52
|
+
str << entry.duration.strftime("%hh %mm")
|
53
|
+
row << str
|
54
|
+
|
55
|
+
# format comment
|
56
|
+
row << entry.comment
|
57
|
+
|
58
|
+
table << row
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
outstream.puts table.as(:text, {:ignore_table_width => true})
|
63
|
+
|
64
|
+
end
|
65
|
+
|
66
|
+
def summary_report(outstream)
|
67
|
+
summary = Hash.new(0)
|
68
|
+
|
69
|
+
total = @entries.inject(0) do |sum, entry|
|
70
|
+
summary[entry.project] += entry.duration.to_i
|
71
|
+
sum += entry.duration.to_i
|
72
|
+
end
|
73
|
+
|
74
|
+
width = summary.keys.max { |a, b| a.length <=> b.length }.length
|
75
|
+
summary.sort.each do |key, value|
|
76
|
+
duration = RichUnits::Duration.new(value)
|
77
|
+
duration.reset_segments(:hours, :minutes)
|
78
|
+
outstream.puts "#{key.rjust(width)} #{duration.strftime("%hh").rjust(4)}#{duration.strftime("%mm").rjust(4)}"
|
79
|
+
end
|
80
|
+
|
81
|
+
total_duration = RichUnits::Duration.new(total)
|
82
|
+
total_duration.reset_segments(:hours, :minutes)
|
83
|
+
outstream.puts "-" * (width + 4 + 4 + 1)
|
84
|
+
outstream.puts "#{"Total".rjust(width)} #{total_duration.strftime("%hh").rjust(4)}#{total_duration.strftime("%mm").rjust(4)}"
|
85
|
+
end
|
86
|
+
|
87
|
+
def byday_report(outstream)
|
88
|
+
|
89
|
+
hash = {}
|
90
|
+
comments = {}
|
91
|
+
@entries.each do |entry|
|
92
|
+
start_date = Date.new(entry.start_time.year, entry.start_time.month, entry.start_time.day)
|
93
|
+
hash[start_date] ||= Hash.new(0)
|
94
|
+
hash[start_date][entry.project] += entry.duration.to_i
|
95
|
+
|
96
|
+
comments[start_date] ||= Hash.new { |h, k| h[k] = [] }
|
97
|
+
comments[start_date][entry.project] << entry.comment
|
98
|
+
end
|
99
|
+
|
100
|
+
hash.sort.each do |entry_date, project_hash|
|
101
|
+
outstream.puts entry_date.strftime("%D")
|
102
|
+
project_hash.sort.each do |project, duration|
|
103
|
+
project_time = RichUnits::Duration.new(duration)
|
104
|
+
project_time.reset_segments(:hours, :minutes)
|
105
|
+
outstream.puts "\t#{project} #{project_time.strftime("%hh %mm")}"
|
106
|
+
comments[entry_date][project].each do |comment|
|
107
|
+
outstream.puts "\t - #{comment}"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
end
|