tempo-cli 0.1.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.
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