timesheet 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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