tempo-cli 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (73) hide show
  1. data/.gitignore +4 -0
  2. data/Gemfile +4 -0
  3. data/Gemfile.lock +56 -0
  4. data/README.md +326 -0
  5. data/Rakefile +65 -0
  6. data/bin/tempo +477 -0
  7. data/features/arrange.feature +43 -0
  8. data/features/checkout.feature +63 -0
  9. data/features/end.feature +65 -0
  10. data/features/project.feature +246 -0
  11. data/features/report.feature +62 -0
  12. data/features/start.feature +87 -0
  13. data/features/step_definitions/tempo_steps.rb +138 -0
  14. data/features/support/env.rb +26 -0
  15. data/features/tempo.feature +13 -0
  16. data/features/update.feature +69 -0
  17. data/lib/file_record/directory.rb +11 -0
  18. data/lib/file_record/directory_structure/tempo/README.txt +4 -0
  19. data/lib/file_record/directory_structure/tempo/tempo_projects.yaml +6 -0
  20. data/lib/file_record/record.rb +120 -0
  21. data/lib/tempo/controllers/arrange_controller.rb +52 -0
  22. data/lib/tempo/controllers/base.rb +117 -0
  23. data/lib/tempo/controllers/checkout_controller.rb +42 -0
  24. data/lib/tempo/controllers/end_controller.rb +42 -0
  25. data/lib/tempo/controllers/projects_controller.rb +107 -0
  26. data/lib/tempo/controllers/records_controller.rb +21 -0
  27. data/lib/tempo/controllers/report_controller.rb +55 -0
  28. data/lib/tempo/controllers/start_controller.rb +42 -0
  29. data/lib/tempo/controllers/update_controller.rb +78 -0
  30. data/lib/tempo/models/base.rb +176 -0
  31. data/lib/tempo/models/composite.rb +71 -0
  32. data/lib/tempo/models/log.rb +194 -0
  33. data/lib/tempo/models/project.rb +73 -0
  34. data/lib/tempo/models/time_record.rb +235 -0
  35. data/lib/tempo/version.rb +3 -0
  36. data/lib/tempo/views/arrange_view.rb +27 -0
  37. data/lib/tempo/views/base.rb +82 -0
  38. data/lib/tempo/views/formatters/base.rb +30 -0
  39. data/lib/tempo/views/formatters/screen.rb +86 -0
  40. data/lib/tempo/views/projects_view.rb +82 -0
  41. data/lib/tempo/views/report_view.rb +26 -0
  42. data/lib/tempo/views/reporter.rb +70 -0
  43. data/lib/tempo/views/time_record_view.rb +30 -0
  44. data/lib/tempo/views/view_records/base.rb +117 -0
  45. data/lib/tempo/views/view_records/composite.rb +40 -0
  46. data/lib/tempo/views/view_records/log.rb +28 -0
  47. data/lib/tempo/views/view_records/project.rb +32 -0
  48. data/lib/tempo/views/view_records/time_record.rb +48 -0
  49. data/lib/tempo.rb +26 -0
  50. data/lib/time_utilities.rb +30 -0
  51. data/tempo-cli.gemspec +26 -0
  52. data/test/lib/file_record/directory_test.rb +30 -0
  53. data/test/lib/file_record/record_test.rb +106 -0
  54. data/test/lib/tempo/controllers/base_controller_test.rb +60 -0
  55. data/test/lib/tempo/controllers/project_controller_test.rb +24 -0
  56. data/test/lib/tempo/models/base_test.rb +173 -0
  57. data/test/lib/tempo/models/composite_test.rb +76 -0
  58. data/test/lib/tempo/models/log_test.rb +171 -0
  59. data/test/lib/tempo/models/project_test.rb +105 -0
  60. data/test/lib/tempo/models/time_record_test.rb +212 -0
  61. data/test/lib/tempo/views/base_test.rb +31 -0
  62. data/test/lib/tempo/views/formatters/base_test.rb +13 -0
  63. data/test/lib/tempo/views/formatters/screen_test.rb +94 -0
  64. data/test/lib/tempo/views/reporter_test.rb +40 -0
  65. data/test/lib/tempo/views/view_records/base_test.rb +77 -0
  66. data/test/lib/tempo/views/view_records/composite_test.rb +57 -0
  67. data/test/lib/tempo/views/view_records/log_test.rb +28 -0
  68. data/test/lib/tempo/views/view_records/project_test.rb +0 -0
  69. data/test/lib/tempo/views/view_records/time_record_test.rb +0 -0
  70. data/test/support/factories.rb +177 -0
  71. data/test/support/helpers.rb +69 -0
  72. data/test/test_helper.rb +31 -0
  73. metadata +230 -0
@@ -0,0 +1,176 @@
1
+ module Tempo
2
+ module Model
3
+
4
+ class IdentityConflictError < Exception
5
+ end
6
+
7
+ class Base
8
+ attr_reader :id
9
+
10
+ class << self
11
+
12
+ # Maintain an array of unique ids for the class.
13
+ # Initialize new members with the next numberical id
14
+ # Ids can be assigned on init (for the purpose of reading
15
+ # in records of previous instances). An error will
16
+ # be raised if there is already an instance with that
17
+ # id.
18
+ def id_counter
19
+ @id_counter ||= 1
20
+ end
21
+
22
+ def ids
23
+ @ids ||= []
24
+ end
25
+
26
+ def index
27
+ @index ||= []
28
+ end
29
+
30
+ # example: Tempo::Model::Animal -> tempo_animals.yaml
31
+ def file
32
+ FileRecord::Record.model_filename self
33
+ end
34
+
35
+ def save_to_file
36
+ FileRecord::Record.save_model self
37
+ end
38
+
39
+ def read_from_file
40
+ FileRecord::Record.read_model self
41
+ end
42
+
43
+ def method_missing meth, *args, &block
44
+
45
+ if meth.to_s =~ /^find_by_(.+)$/
46
+ run_find_by_method($1, *args, &block)
47
+
48
+ elsif meth.to_s =~ /^sort_by_(.+)$/
49
+ run_sort_by_method($1, *args, &block)
50
+ else
51
+ super
52
+ end
53
+ end
54
+
55
+ def run_sort_by_method attribute, args=@index.clone, &block
56
+ attr = "@#{attribute}".to_sym
57
+ args.sort! { |a,b| a.instance_variable_get( attr ) <=> b.instance_variable_get( attr ) }
58
+ return args unless block
59
+ block.call args
60
+ end
61
+
62
+ def run_find_by_method attrs, *args, &block
63
+ # Make an array of attribute names
64
+ attrs = attrs.split('_and_')
65
+
66
+ attrs_with_args = [attrs, args].transpose
67
+
68
+ filtered = index.clone
69
+ attrs_with_args.each do | kv |
70
+ matches = find kv[0], kv[1]
71
+
72
+ return matches if matches.empty?
73
+ matches.each do |match|
74
+ matches.delete match unless filtered.include? match
75
+ filtered = matches
76
+ end
77
+ end
78
+ filtered
79
+ end
80
+
81
+ # find by id should be exact, so we remove the array wrapper
82
+ def find_by_id id
83
+ matches = find "id", id
84
+ match = matches[0]
85
+ end
86
+
87
+ # example: Tempo::Model.find("id", 1)
88
+ #
89
+ def find key, value
90
+ key = "@#{key}".to_sym
91
+ matches = []
92
+ index.each do |i|
93
+ stored_value = i.instance_variable_get( key )
94
+
95
+ if stored_value.kind_of? String
96
+ if value.kind_of? Regexp
97
+ matches << i if value.match stored_value
98
+ else
99
+ matches << i if stored_value.downcase.include? value.to_s.downcase
100
+ end
101
+
102
+ elsif stored_value.kind_of? Integer
103
+ matches << i if stored_value == value.to_i
104
+ end
105
+ end
106
+ matches
107
+ end
108
+
109
+ def delete instance
110
+ id = instance.id
111
+ index.delete( instance )
112
+ ids.delete( id )
113
+ end
114
+ end
115
+
116
+ def initialize options={}
117
+ id_candidate = options[:id]
118
+ if !id_candidate
119
+ @id = self.class.next_id
120
+ elsif self.class.ids.include? id_candidate
121
+ raise IdentityConflictError, "Id #{id_candidate} already exists"
122
+ else
123
+ @id = id_candidate
124
+ end
125
+ self.class.add_id @id
126
+ self.class.add_to_index self
127
+ end
128
+
129
+ # record the state of all instance variables as a hash
130
+ def freeze_dry
131
+ record = {}
132
+ state = instance_variables
133
+ state.each do |attr|
134
+ key = attr[1..-1].to_sym
135
+ val = instance_variable_get attr
136
+
137
+ #val = val.to_s if val.kind_of? Time
138
+
139
+ record[key] = val
140
+ end
141
+ record
142
+ end
143
+
144
+ def delete
145
+ self.class.delete self
146
+ end
147
+
148
+ protected
149
+
150
+ def self.add_to_index member
151
+ @index ||= []
152
+ @index << member
153
+ @index.sort! { |a,b| a.id <=> b.id }
154
+ end
155
+
156
+ def self.add_id id
157
+ @ids ||=[]
158
+ @ids << id
159
+ @ids.sort!
160
+ end
161
+
162
+ def self.increase_id_counter
163
+ @id_counter ||= 0
164
+ @id_counter = @id_counter.next
165
+ end
166
+
167
+ def self.next_id
168
+ while ids.include? id_counter
169
+ increase_id_counter
170
+ end
171
+ id_counter
172
+ end
173
+ end
174
+ end
175
+ end
176
+
@@ -0,0 +1,71 @@
1
+ # Composite Model extends base to accomodate tree structures
2
+ # Each instance can be a root instance, or a child of another
3
+ # instance, and each instance can have any number of children.
4
+ # report_trees is a utility method for testing the validity of the
5
+ # model, and cam be used as a template for creating tree reports.
6
+
7
+ module Tempo
8
+ module Model
9
+ class Composite < Tempo::Model::Base
10
+ attr_accessor :parent, :children
11
+
12
+ class << self
13
+
14
+ def report_trees
15
+ report_array = "["
16
+ @index.each do |member|
17
+ if member.parent == :root
18
+ report_array += "["
19
+ report_array += member.report_branches
20
+ report_array += "],"
21
+ end
22
+ end
23
+ if report_array[-1] == ","
24
+ report_array = report_array[0..-2]
25
+ end
26
+ report_array += "]"
27
+ end
28
+
29
+ def delete instance
30
+ instance.children.each do |child_id|
31
+ child = find_by_id child_id
32
+ instance.remove_child child
33
+ end
34
+ super instance
35
+ end
36
+ end
37
+
38
+ def initialize(options={})
39
+ super options
40
+ @parent = options.fetch(:parent, :root)
41
+ @children = options.fetch(:children, [])
42
+ end
43
+
44
+ def << child
45
+ @children << child.id unless @children.include? child.id
46
+ @children.sort!
47
+ child.parent = self.id
48
+ end
49
+
50
+ def remove_child( child )
51
+ @children.delete child.id
52
+ child.parent = :root
53
+ end
54
+
55
+ def report_branches
56
+ report = self.id.to_s
57
+ child_report = ",["
58
+ @children.each do |c|
59
+ child = self.class.find_by_id c
60
+ child_report += "#{child.report_branches},"
61
+ end
62
+ if child_report == ",["
63
+ child_report = ""
64
+ else
65
+ child_report = child_report[0..-2] + "]"
66
+ end
67
+ report += child_report
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,194 @@
1
+ module Tempo
2
+ module Model
3
+ class Log < Tempo::Model::Base
4
+ attr_accessor :start_time
5
+ attr_reader :d_id
6
+
7
+ class << self
8
+
9
+ # Maintain arrays of unique ids for each day.
10
+ # days are represented as symbols in the hash,
11
+ # for example Jan 1, 2013 would be :"130101"
12
+ # id counter is managed through the private methods
13
+ # increase_id_counter and next_id below
14
+ def id_counter time
15
+ dsym = date_symbol time
16
+ @id_counter = {} unless @id_counter.kind_of? Hash
17
+ @id_counter[ dsym ] ||= 1
18
+ end
19
+
20
+ def ids time
21
+ dsym = date_symbol time
22
+ @ids = {} unless @ids.kind_of? Hash
23
+ @ids[dsym] ||= []
24
+ end
25
+
26
+ # all instances are saved in the index inherited from base.
27
+ # Additionally, the days index organizes all instances into
28
+ # arrays by day. This is used for saving to file.
29
+ def days_index
30
+ @days_index = {} unless @days_index.kind_of? Hash
31
+ @days_index
32
+ end
33
+
34
+ def file time
35
+ FileRecord::Record.log_filename( self, time )
36
+ end
37
+
38
+ def dir
39
+ FileRecord::Record.log_dirname( self )
40
+ end
41
+
42
+ def records
43
+ path = FileRecord::Record.log_dir( self )
44
+ Dir[path + "/*.yaml"]
45
+ end
46
+
47
+ def save_to_file
48
+ FileRecord::Record.save_log( self )
49
+ end
50
+
51
+
52
+ def read_from_file time
53
+ dsym = date_symbol time
54
+ @days_index[ dsym ] = [] if not days_index.has_key? dsym
55
+ FileRecord::Record.read_log( self, time )
56
+ end
57
+
58
+ # load all the records for a single day
59
+ def load_day_record time
60
+ dsym = date_symbol time
61
+ if not days_index.has_key? dsym
62
+ @days_index[ dsym ] = []
63
+ read_from_file time
64
+ end
65
+ end
66
+
67
+ # load the records for each day from time 1 to time 2
68
+ def load_days_records time_1, time_2
69
+
70
+ return if time_1.nil? || time_2.nil?
71
+
72
+ days = ( time_2.to_date - time_1.to_date ).to_i
73
+ return if days < 0
74
+
75
+ (days + 1).times { |i| load_day_record( time_1.add_days( i ))}
76
+ end
77
+
78
+ # load the records for the most recently recorded day
79
+ def load_last_day
80
+ reg = /(\d+)\.yaml/
81
+ if records.last
82
+ d_id = reg.match(records.last)[1] if records.last
83
+ time = day_id_to_time d_id if d_id
84
+ load_day_record time
85
+ return time
86
+ end
87
+ end
88
+
89
+ # takes and integer, and time or day_id
90
+ # and returns the instance that matches both
91
+ # the id and d_id
92
+ def find_by_id id, time
93
+ time = day_id time
94
+ ids = find "id", id
95
+ d_ids = find "d_id", time
96
+
97
+ #return the first and only match in the union
98
+ #of the arrays
99
+ (ids & d_ids)[0]
100
+ end
101
+
102
+ # day_ids can be run through without change
103
+ # Time will be converted into "YYYYmmdd"
104
+ # ex: 1-1-2014 => "20140101"
105
+ def day_id time
106
+ if time.kind_of? String
107
+ return time if time =~ /^\d{8}$/
108
+ end
109
+ raise ArgumentError, "Invalid Time" if not time.kind_of? Time
110
+ time.strftime("%Y%m%d")
111
+ end
112
+
113
+ def day_id_to_time d_id
114
+ time = Time.new(d_id[0..3].to_i, d_id[4..5].to_i, d_id[6..7].to_i)
115
+ end
116
+
117
+ def delete instance
118
+ id = instance.id
119
+ dsym = date_symbol instance.d_id
120
+
121
+ index.delete instance
122
+ days_index[dsym].delete instance
123
+ @ids[dsym].delete id
124
+ end
125
+ end
126
+
127
+ def initialize( options={} )
128
+ @start_time = options.fetch(:start_time, Time.now )
129
+ @start_time = Time.new(@start_time) if @start_time.kind_of? String
130
+
131
+ self.class.load_day_record(@start_time)
132
+ @d_id = self.class.day_id @start_time
133
+
134
+ id_candidate = options[:id]
135
+ if !id_candidate
136
+ @id = self.class.next_id @start_time
137
+ elsif self.class.ids( @start_time ).include? id_candidate
138
+ raise IdentityConflictError, "Id #{id_candidate} already exists"
139
+ else
140
+ @id = id_candidate
141
+ end
142
+
143
+ self.class.add_id @start_time, @id
144
+ self.class.add_to_index self
145
+ self.class.add_to_days_index self
146
+ end
147
+
148
+ def freeze_dry
149
+ record = super
150
+ record.delete(:d_id)
151
+ record
152
+ end
153
+
154
+ protected
155
+
156
+ class << self
157
+
158
+ def add_to_days_index member
159
+ @days_index = {} unless @days_index.kind_of? Hash
160
+ dsym = date_symbol member.start_time
161
+ @days_index[dsym] ||= []
162
+ @days_index[dsym] << member
163
+ @days_index[dsym].sort! { |a,b| a.start_time <=> b.start_time }
164
+ end
165
+
166
+ def add_id time, id
167
+ dsym = date_symbol time
168
+ @ids = {} unless @ids.kind_of? Hash
169
+ @ids[dsym] ||= []
170
+ @ids[dsym] << id
171
+ @ids[dsym].sort!
172
+ end
173
+
174
+ def date_symbol time
175
+ day_id( time ).to_sym
176
+ end
177
+
178
+ def increase_id_counter time
179
+ dsym = date_symbol time
180
+ @id_counter = {} unless @id_counter.kind_of? Hash
181
+ @id_counter[ dsym ] ||= 0
182
+ @id_counter[ dsym ] = @id_counter[ dsym ].next
183
+ end
184
+
185
+ def next_id time
186
+ while ids(time).include? id_counter time
187
+ increase_id_counter time
188
+ end
189
+ id_counter time
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,73 @@
1
+ module Tempo
2
+ module Model
3
+ class Project < Tempo::Model::Composite
4
+ attr_accessor :title
5
+ attr_reader :tags
6
+ @current = 0
7
+
8
+ class << self
9
+
10
+ def current( instance=nil )
11
+ @current
12
+ end
13
+
14
+ def current=( instance )
15
+ if instance.class == self
16
+ @current = instance
17
+ else
18
+ raise ArgumentError
19
+ end
20
+ end
21
+
22
+ def include?( title )
23
+ matches = find_by_title( title )
24
+ return false if matches.empty?
25
+ matches.each do |match|
26
+ return true if match.title == title
27
+ end
28
+ false
29
+ end
30
+ end
31
+
32
+ def initialize(options={})
33
+ super options
34
+ @title = options.fetch(:title, "new project")
35
+ @tags = []
36
+ tag options.fetch(:tags, [])
37
+ current = options.fetch(:current, false)
38
+ self.class.current = self if current
39
+ end
40
+
41
+ def current?
42
+ self.class.current == self
43
+ end
44
+
45
+ def freeze_dry
46
+ record = super
47
+ if self.class.current == self
48
+ record[:current] = true
49
+ end
50
+ record
51
+ end
52
+
53
+ def tag( tags )
54
+ return unless tags and tags.kind_of? Array
55
+ tags.each do |tag|
56
+ tag.split.each {|t| @tags << t if ! @tags.include? t }
57
+ end
58
+ @tags.sort!
59
+ end
60
+
61
+ def untag( tags )
62
+ return unless tags and tags.kind_of? Array
63
+ tags.each do |tag|
64
+ tag.split.each {|t| @tags.delete t }
65
+ end
66
+ end
67
+
68
+ def to_s
69
+ puts "id: #{id}, title: #{title}, tags: #{tags}"
70
+ end
71
+ end
72
+ end
73
+ end