timesheet 0.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.
@@ -0,0 +1,4 @@
1
+ === 0.0.1 2009-11-29
2
+
3
+ * 1 major enhancement:
4
+ * Initial release
@@ -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
@@ -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.
@@ -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
+
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib'
4
+
5
+ require 'timesheet'
6
+
7
+ Timesheet.run(ARGV)
@@ -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