timr 0.3.0 → 0.4.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.ackrc +9 -0
  3. data/.editorconfig +1 -0
  4. data/.env.example +7 -0
  5. data/.github/CONTRIBUTING.md +32 -0
  6. data/.github/ISSUE_TEMPLATE.md +13 -0
  7. data/.gitignore +8 -2
  8. data/.rdoc_options +21 -0
  9. data/.travis.yml +10 -7
  10. data/Gemfile +8 -0
  11. data/README.md +216 -3
  12. data/bin/.gitignore +2 -0
  13. data/bin/README.md +17 -0
  14. data/bin/build.sh +14 -0
  15. data/bin/build_api.sh +14 -0
  16. data/bin/build_coverage.sh +23 -0
  17. data/bin/build_info.sh +27 -0
  18. data/bin/build_man.sh +41 -0
  19. data/bin/clean.sh +14 -0
  20. data/bin/dev_setup.sh +19 -0
  21. data/bin/install.sh +49 -0
  22. data/bin/publish +38 -0
  23. data/bin/release.sh +35 -0
  24. data/bin/test.sh +19 -0
  25. data/bin/timr +20 -40
  26. data/bin/timr_bash_completion.sh +337 -0
  27. data/bin/uninstall.sh +24 -0
  28. data/lib/timr.rb +36 -8
  29. data/lib/timr/command/basic_command.rb +170 -0
  30. data/lib/timr/command/continue_command.rb +86 -0
  31. data/lib/timr/command/help_command.rb +137 -0
  32. data/lib/timr/command/log_command.rb +297 -0
  33. data/lib/timr/command/pause_command.rb +89 -0
  34. data/lib/timr/command/pop_command.rb +176 -0
  35. data/lib/timr/command/push_command.rb +141 -0
  36. data/lib/timr/command/report_command.rb +689 -0
  37. data/lib/timr/command/start_command.rb +172 -0
  38. data/lib/timr/command/status_command.rb +198 -0
  39. data/lib/timr/command/stop_command.rb +127 -0
  40. data/lib/timr/command/task_command.rb +318 -0
  41. data/lib/timr/command/track_command.rb +381 -0
  42. data/lib/timr/command/version_command.rb +18 -0
  43. data/lib/timr/duration.rb +159 -0
  44. data/lib/timr/exception/timr_error.rb +113 -0
  45. data/lib/timr/ext/time.rb +12 -0
  46. data/lib/timr/helper/datetime_helper.rb +128 -0
  47. data/lib/timr/helper/terminal_helper.rb +58 -0
  48. data/lib/timr/helper/translation_helper.rb +45 -0
  49. data/lib/timr/model/basic_model.rb +287 -0
  50. data/lib/timr/model/config.rb +48 -0
  51. data/lib/timr/model/foreign_id_db.rb +84 -0
  52. data/lib/timr/model/stack.rb +161 -0
  53. data/lib/timr/model/task.rb +1039 -0
  54. data/lib/timr/model/track.rb +589 -0
  55. data/lib/timr/progressbar.rb +41 -0
  56. data/lib/timr/simple_opt_parser.rb +230 -0
  57. data/lib/timr/status.rb +70 -0
  58. data/lib/timr/table.rb +88 -0
  59. data/lib/timr/timr.rb +500 -558
  60. data/lib/timr/version.rb +4 -15
  61. data/man/.gitignore +2 -0
  62. data/man/_footer +3 -0
  63. data/man/timr-continue.1 +48 -0
  64. data/man/timr-continue.1.ronn +39 -0
  65. data/man/timr-ftime.7 +77 -0
  66. data/man/timr-ftime.7.ronn +57 -0
  67. data/man/timr-log.1 +109 -0
  68. data/man/timr-log.1.ronn +87 -0
  69. data/man/timr-pause.1 +56 -0
  70. data/man/timr-pause.1.ronn +45 -0
  71. data/man/timr-pop.1 +66 -0
  72. data/man/timr-pop.1.ronn +53 -0
  73. data/man/timr-push.1 +25 -0
  74. data/man/timr-push.1.ronn +20 -0
  75. data/man/timr-report.1 +228 -0
  76. data/man/timr-report.1.ronn +193 -0
  77. data/man/timr-start.1 +100 -0
  78. data/man/timr-start.1.ronn +82 -0
  79. data/man/timr-status.1 +53 -0
  80. data/man/timr-status.1.ronn +42 -0
  81. data/man/timr-stop.1 +75 -0
  82. data/man/timr-stop.1.ronn +60 -0
  83. data/man/timr-task.1 +147 -0
  84. data/man/timr-task.1.ronn +115 -0
  85. data/man/timr-track.1 +109 -0
  86. data/man/timr-track.1.ronn +89 -0
  87. data/man/timr.1 +119 -0
  88. data/man/timr.1.ronn +68 -0
  89. data/timr.gemspec +18 -3
  90. data/timr.sublime-project +20 -1
  91. metadata +142 -23
  92. data/Makefile +0 -12
  93. data/Makefile.common +0 -56
  94. data/lib/timr/stack.rb +0 -81
  95. data/lib/timr/task.rb +0 -258
  96. data/lib/timr/track.rb +0 -167
  97. data/lib/timr/window.rb +0 -259
  98. data/lib/timr/window_help.rb +0 -41
  99. data/lib/timr/window_tasks.rb +0 -30
  100. data/lib/timr/window_test.rb +0 -20
  101. data/lib/timr/window_timeline.rb +0 -33
  102. data/tests/tc_stack.rb +0 -121
  103. data/tests/tc_task.rb +0 -190
  104. data/tests/tc_track.rb +0 -144
  105. data/tests/tc_window.rb +0 -428
  106. data/tests/ts_all.rb +0 -6
@@ -0,0 +1,161 @@
1
+
2
+ module TheFox
3
+ module Timr
4
+ module Model
5
+
6
+ # The Stack holds one or more [Tracks](rdoc-ref:TheFox::Timr::Model::Track). Only one Track can run at a time.
7
+ #
8
+ # When you push a new Track on the Stack the underlying running will be paused.
9
+ #
10
+ # Do not call Stack methods from extern. Only the Timr class is responsible to call Stack methods.
11
+ class Stack < BasicModel
12
+
13
+ include TheFox::Timr::Helper
14
+ include TheFox::Timr::Error
15
+
16
+ # Timr instance.
17
+ attr_accessor :timr
18
+
19
+ # Holds all Tracks.
20
+ attr_reader :tracks
21
+
22
+ def initialize
23
+ super()
24
+
25
+ @timr = nil
26
+
27
+ # Data
28
+ @tracks = Array.new
29
+ end
30
+
31
+ # Get the current Track (Top Track).
32
+ def current_track
33
+ @tracks.last
34
+ end
35
+
36
+ # Start a Track.
37
+ def start(track)
38
+ unless track.is_a?(Track)
39
+ raise StackError, "track variable must be a Track instance. #{track.class} given."
40
+ end
41
+
42
+ stop
43
+
44
+ @tracks = Array.new
45
+ @tracks << track
46
+
47
+ # Mark Stack as changed.
48
+ changed
49
+ end
50
+
51
+ # Stop current running Track.
52
+ def stop
53
+ if @tracks.count > 0
54
+ @tracks.pop
55
+
56
+ # Mark Stack as changed.
57
+ changed
58
+ end
59
+ end
60
+
61
+ # Push a Track.
62
+ def push(track)
63
+ unless track.is_a?(Track)
64
+ raise StackError, "track variable must be a Track instance. #{track.class} given."
65
+ end
66
+
67
+ @tracks << track
68
+
69
+ # Mark Stack as changed.
70
+ changed
71
+ end
72
+
73
+ # Remove a Track.
74
+ def remove(track)
75
+ unless track.is_a?(Track)
76
+ raise StackError, "track variable must be a Track instance. #{track.class} given."
77
+ end
78
+
79
+ @tracks.delete(track)
80
+
81
+ # Mark Stack as changed.
82
+ changed
83
+ end
84
+
85
+ # Check Track on Stack.
86
+ def on_stack?(track)
87
+ unless track.is_a?(Track)
88
+ raise StackError, "track variable must be a Track instance. #{track.class} given."
89
+ end
90
+
91
+ @tracks.include?(track)
92
+ end
93
+
94
+ # Append a Track.
95
+ def <<(track)
96
+ @tracks << track
97
+ end
98
+
99
+ # To String
100
+ def to_s
101
+ tracks_s = TranslationHelper.pluralize(@tracks.count, 'track', 'tracks')
102
+ 'Stack: %s' % [tracks_s]
103
+ end
104
+
105
+ def inspect
106
+ "#<Stack tracks=#{@tracks.count} current=#{@current_track.short_id}>"
107
+ end
108
+
109
+ private
110
+
111
+ # BasicModel Hook
112
+ def pre_save_to_file
113
+ # Tracks
114
+ @data = @tracks.map{ |track| [track.task.id, track.id] }
115
+
116
+ super()
117
+ end
118
+
119
+ # BasicModel Hook
120
+ def post_load_from_file
121
+ unless @timr
122
+ raise StackError, 'Stack: @timr variable is not set.'
123
+ end
124
+
125
+ @tracks = @data.map{ |ids|
126
+ task_id, track_id = ids
127
+
128
+ begin
129
+ task = @timr.get_task_by_id(task_id)
130
+ if task
131
+ track = task.find_track_by_id(track_id)
132
+
133
+ if track.nil?
134
+ # Task file was found but no Track with ID from Stack.
135
+
136
+ # Mark Stack as changed.
137
+ changed
138
+
139
+ nil
140
+ else
141
+ track
142
+ end
143
+ end
144
+ rescue TimrError
145
+ # Task file for ID from Stack was not found.
146
+
147
+ # Mark Stack as changed.
148
+ changed
149
+
150
+ nil
151
+ end
152
+ }.select{ |track|
153
+ !track.nil?
154
+ }
155
+ end
156
+
157
+ end # class Task
158
+
159
+ end # module Model
160
+ end # module Timr
161
+ end #module TheFox
@@ -0,0 +1,1039 @@
1
+
2
+ module TheFox
3
+ module Timr
4
+ module Model
5
+
6
+ class Task < BasicModel
7
+
8
+ include TheFox::Timr::Error
9
+
10
+ attr_reader :foreign_id
11
+ attr_reader :description
12
+ attr_reader :current_track
13
+ attr_reader :hourly_rate
14
+ attr_reader :has_flat_rate
15
+
16
+ def initialize
17
+ super()
18
+
19
+ # Meta
20
+ @foreign_id = nil # --id
21
+ @name = nil
22
+ @description = nil
23
+ @current_track = nil
24
+ @estimation = nil
25
+ @hourly_rate = nil
26
+ @has_flat_rate = false
27
+
28
+ # Data
29
+ @tracks = Hash.new
30
+ end
31
+
32
+ def foreign_id=(foreign_id)
33
+ @foreign_id = foreign_id
34
+
35
+ # Mark Task as changed.
36
+ changed
37
+ end
38
+
39
+ # Set name.
40
+ def name=(name)
41
+ @name = name
42
+
43
+ # Mark Task as changed.
44
+ changed
45
+ end
46
+
47
+ # Get name.
48
+ def name(max_length = nil)
49
+ name = @name
50
+ if name && max_length && name.length > max_length + 2
51
+ name = name[0, max_length] << '...'
52
+ end
53
+ name
54
+ end
55
+
56
+ # Get name or `---` if name is not set.
57
+ def name_s(max_length = nil)
58
+ s = name(max_length)
59
+ if s.nil?
60
+ '---'
61
+ else
62
+ s
63
+ end
64
+ end
65
+
66
+ # Set description.
67
+ def description=(description)
68
+ @description = description
69
+
70
+ # Mark Task as changed.
71
+ changed
72
+ end
73
+
74
+ # Add a Track.
75
+ def add_track(track, set_as_current_track = false)
76
+ track.task = self
77
+
78
+ @tracks[track.id] = track
79
+
80
+ if set_as_current_track
81
+ @current_track = track
82
+ end
83
+
84
+ # Mark Task as changed.
85
+ changed
86
+ end
87
+
88
+ # Remove a Track.
89
+ def remove_track(track)
90
+ track.task = nil
91
+
92
+ if @tracks.delete(track.id)
93
+ # Mark Task as changed.
94
+ changed
95
+ else
96
+ # Track is not assiged to this Task.
97
+ false
98
+ end
99
+ end
100
+
101
+ # Move a Track to another Task.
102
+ def move_track(track, target_task)
103
+ if eql?(target_task)
104
+ return false
105
+ end
106
+
107
+ unless remove_track(track)
108
+ return false
109
+ end
110
+
111
+ set_as_current_track = false
112
+ if @current_track && @current_track.eql?(track)
113
+ @current_track = nil
114
+ set_as_current_track = true
115
+ end
116
+
117
+ target_task.add_track(track, set_as_current_track)
118
+
119
+ true
120
+ end
121
+
122
+ # Select Track by Time Range and/or Status.
123
+ #
124
+ # Options:
125
+ #
126
+ # - `:from`, `:to` limit the begin and end datetimes to a specific range.
127
+ # - `:status` filter Tracks by Short Status.
128
+ # - `:billed` filter Tracks by is_billed flag.
129
+ # - `true` filter billed Tracks.
130
+ # - `false` filter unbilled Tracks.
131
+ # - `nil` filter off.
132
+ #
133
+ # Fixed Start and End (`from != nil && to != nil`)
134
+ #
135
+ # ```
136
+ # Selected Range |----------|
137
+ # Track A +-----------------+
138
+ # Track B +------+
139
+ # Track C +------------+
140
+ # Track D +----+
141
+ # Track E +---+
142
+ # Track F +---+
143
+ # ```
144
+ #
145
+ # * Track A is bigger then the Options range. Take it.
146
+ # * Track B ends in the Options range. Take it.
147
+ # * Track C starts in the Options range. Take it.
148
+ # * Track D starts and ends within the Options range. Definitely take this.
149
+ # * Track E is out-of-score. Ignore it.
150
+ # * Track F is out-of-score. Ignore it.
151
+ #
152
+ # ---
153
+ #
154
+ # Open End (`to == nil`)
155
+ # Take all except Track E.
156
+ #
157
+ # ```
158
+ # Selected Range |---------->
159
+ # Track A +-----------------+
160
+ # Track B +------+
161
+ # Track C +------------+
162
+ # Track D +----+
163
+ # Track E +---+
164
+ # Track F +---+
165
+ # ```
166
+ #
167
+ # ---
168
+ #
169
+ # Open Start (`from == nil`)
170
+ # Take all except Track F.
171
+ #
172
+ # ```
173
+ # Selected Range <----------|
174
+ # Track A +-----------------+
175
+ # Track B +------+
176
+ # Track C +------------+
177
+ # Track D +----+
178
+ # Track E +---+
179
+ # Track F +---+
180
+ # ```
181
+ def tracks(options = Hash.new)
182
+ from_opt = options.fetch(:from, nil)
183
+ to_opt = options.fetch(:to, nil)
184
+ status_opt = options.fetch(:status, nil)
185
+ sort_opt = options.fetch(:sort, true)
186
+ billed_opt = options.fetch(:billed, nil)
187
+
188
+ if status_opt
189
+ case status_opt
190
+ when String
191
+ status_opt = [status_opt]
192
+ when Array
193
+ # OK
194
+ else
195
+ raise TaskError, ":status needs to be an instance of String or Array, #{status_opt.class} given."
196
+ end
197
+ end
198
+
199
+ if from_opt && to_opt && from_opt > to_opt
200
+ raise TaskError, 'From cannot be bigger than To.'
201
+ end
202
+
203
+ filtered_tracks = Hash.new
204
+ if from_opt.nil? && to_opt.nil?
205
+ # Take all Tracks.
206
+ filtered_tracks = @tracks.select{ |track_id, track|
207
+ # Filter Tracks with no Begin DateTime.
208
+ # This can happen when 'timr track add' without any DateTime.
209
+ !track.begin_datetime.nil?
210
+ }
211
+ elsif !from_opt.nil? && to_opt.nil?
212
+ # Open End (to_opt == nil)
213
+ filtered_tracks = @tracks.select{ |track_id, track|
214
+ bdt = track.begin_datetime
215
+ edt = track.end_datetime || Time.now
216
+
217
+ bdt && (
218
+ bdt < from_opt && edt > from_opt || # Track A, B
219
+ bdt >= from_opt && edt >= from_opt # Track C, D, F
220
+ )
221
+ }
222
+ elsif from_opt.nil? && !to_opt.nil?
223
+ # Open Start (from_opt == nil)
224
+ filtered_tracks = @tracks.select{ |track_id, track|
225
+ bdt = track.begin_datetime
226
+ edt = track.end_datetime || Time.now
227
+
228
+ bdt && (
229
+ bdt < to_opt && edt <= to_opt || # Track B, D, E
230
+ bdt < to_opt && edt > to_opt # Track A, C
231
+ )
232
+ }
233
+ elsif !from_opt.nil? && !to_opt.nil?
234
+ # Fixed Start and End (from_opt != nil && to_opt != nil)
235
+ filtered_tracks = @tracks.select{ |track_id, track|
236
+ bdt = track.begin_datetime
237
+ edt = track.end_datetime || Time.now
238
+
239
+ bdt && (
240
+ bdt >= from_opt && edt <= to_opt || # Track D
241
+ bdt < from_opt && edt > to_opt || # Track A
242
+ bdt < from_opt && edt <= to_opt && edt > from_opt || # Track B
243
+ bdt >= from_opt && edt > to_opt && bdt < to_opt # Track C
244
+ )
245
+ }
246
+ else
247
+ raise ThisShouldNeverHappenError, 'Should never happen, bug shit happens.'
248
+ end
249
+
250
+ if status_opt
251
+ filtered_tracks.select!{ |track_id, track|
252
+ status_opt.include?(track.status.short_status)
253
+ }
254
+ end
255
+
256
+ unless billed_opt.nil?
257
+ if billed_opt
258
+ filtered_tracks.select!{ |track_id, track|
259
+ track.is_billed
260
+ }
261
+ else
262
+ filtered_tracks.select!{ |track_id, track|
263
+ !track.is_billed
264
+ }
265
+ end
266
+ end
267
+
268
+ if sort_opt
269
+ filtered_tracks.sort{ |t1, t2|
270
+ t1 = t1.last
271
+ t2 = t2.last
272
+
273
+ cmp1 = t1.begin_datetime <=> t2.begin_datetime
274
+ if cmp1.nil? || cmp1 == 0
275
+ t1.end_datetime <=> t2.end_datetime
276
+ else
277
+ cmp1
278
+ end
279
+ }.to_h
280
+ else
281
+ filtered_tracks
282
+ end
283
+ end
284
+
285
+ # Uses `tracks()` with `options` to filter.
286
+ #
287
+ # Options:
288
+ #
289
+ # - `:from`
290
+ def begin_datetime(options = Hash.new)
291
+ from_opt = options.fetch(:from, nil)
292
+
293
+ # Cache
294
+ # if @begin_datetime
295
+ # return @begin_datetime
296
+ # end
297
+ # Cannot use this cache because of :from :to range limitation.
298
+ # It needs always to be direct from child Tracks, because the
299
+ # cache does not know when the begin and end datetimes of the
300
+ # child Tracks change.
301
+
302
+ # Do not sort. We only need to sort the tracks
303
+ # by begin_datetime and take the first.
304
+ options[:sort] = false
305
+
306
+ first_track = tracks(options)
307
+ .select{ |track_id, track| track.begin_datetime } # filter nil
308
+ .sort_by{ |track_id, track| track.begin_datetime }
309
+ .to_h # sort_by makes [[]]
310
+ .values # no keys to take the first
311
+ .first
312
+
313
+ if first_track
314
+ bdt = first_track.begin_datetime(options)
315
+ end
316
+
317
+ if from_opt && bdt && from_opt > bdt
318
+ bdt = from_opt
319
+ end
320
+
321
+ bdt
322
+ end
323
+
324
+ # Options:
325
+ #
326
+ # - `:format`
327
+ def begin_datetime_s(options = Hash.new)
328
+ format_opt = options.fetch(:format, HUMAN_DATETIME_FOMRAT)
329
+
330
+ bdt = begin_datetime(options)
331
+ if bdt
332
+ bdt.strftime(format_opt)
333
+ else
334
+ '---'
335
+ end
336
+ end
337
+
338
+ # Uses `tracks()` with `options` to filter.
339
+ #
340
+ # Options:
341
+ #
342
+ # - `:to`
343
+ def end_datetime(options = Hash.new)
344
+ to_opt = options.fetch(:to, nil)
345
+
346
+ # Cache
347
+ # if @end_datetime
348
+ # return @end_datetime
349
+ # end
350
+ # Cannot use this cache because of :from :to range limitation.
351
+ # It needs always to be direct from child Tracks, because the
352
+ # cache does not know when the begin and end datetimes of the
353
+ # child Tracks change.
354
+
355
+ # Do not sort. We only need to sort the tracks
356
+ # by end_datetime and take the last.
357
+ options[:sort] = false
358
+
359
+ last_track = tracks(options)
360
+ .select{ |track_id, track| track.end_datetime } # filter nil
361
+ .sort_by{ |track_id, track| track.end_datetime }
362
+ .to_h # sort_by makes [[]]
363
+ .values # no keys to take the last
364
+ .last
365
+
366
+ if last_track
367
+ edt = last_track.end_datetime(options)
368
+ end
369
+
370
+ if to_opt && edt && to_opt < edt
371
+ edt = to_opt
372
+ end
373
+
374
+ edt
375
+ end
376
+
377
+ # Options:
378
+ #
379
+ # - `:format`
380
+ def end_datetime_s(options = Hash.new)
381
+ format_opt = options.fetch(:format, HUMAN_DATETIME_FOMRAT)
382
+
383
+ edt = end_datetime(options)
384
+ if edt
385
+ edt.strftime(format_opt)
386
+ else
387
+ '---'
388
+ end
389
+ end
390
+
391
+ # Set estimation.
392
+ #
393
+ # Either using a Duration instance, Integer or a String like `2h 30m`.
394
+ # Estimation is parsed by [chronic_duration](https://github.com/henrypoydar/chronic_duration).
395
+ #
396
+ # Examples:
397
+ #
398
+ # - `-e 2:10:5`
399
+ # Sets Estimation to 2h 10m 5s.
400
+ #
401
+ # - `-e '2h 10m 5s'`
402
+ # Sets Estimation to 2h 10m 5s.
403
+ #
404
+ # Use `+` or `-` to calculate with Estimation Times:
405
+ #
406
+ # - `-e '-45m'`
407
+ # Subtracts 45 minutes from the original Estimation.
408
+ # - `-e '+1h 30m'`
409
+ # Adds 1 hour 30 minutes to the original Estimation.
410
+ #
411
+ # See [chronic_duration](https://github.com/henrypoydar/chronic_duration) for more examples.
412
+ def estimation=(estimation)
413
+ case estimation
414
+ when String
415
+ # Cannot use estimation.strip! because frozen.
416
+ estimation = estimation.strip
417
+
418
+ if estimation[0] == '+'
419
+ estimation = estimation[1..-1]
420
+ @estimation += Duration.parse(estimation)
421
+ elsif estimation[0] == '-'
422
+ estimation = estimation[1..-1]
423
+ @estimation -= Duration.parse(estimation)
424
+ else
425
+ @estimation = Duration.parse(estimation)
426
+ end
427
+ when Integer
428
+ @estimation = Duration.new(estimation)
429
+ when Duration
430
+ @estimation = estimation
431
+ when nil
432
+ @estimation = estimation
433
+ else
434
+ raise TaskError, "estimation needs to be an instance of String, Integer, Duration or nil, #{estimation.class} given."
435
+ end
436
+
437
+ # Mark Task as changed.
438
+ changed
439
+ end
440
+
441
+ # Get estimation.
442
+ def estimation
443
+ @estimation
444
+ end
445
+
446
+ # Get estimation as String.
447
+ def estimation_s
448
+ if @estimation
449
+ @estimation.to_human
450
+ else
451
+ '---'
452
+ end
453
+ end
454
+
455
+ # Set hourly_rate.
456
+ def hourly_rate=(new_hourly_rate)
457
+ if new_hourly_rate.nil?
458
+ @hourly_rate = nil
459
+ else
460
+ @hourly_rate = new_hourly_rate.to_f
461
+ end
462
+
463
+ # Mark Task as changed.
464
+ changed
465
+ end
466
+
467
+ # Set has_flat_rate.
468
+ def has_flat_rate=(has_flat_rate)
469
+ @has_flat_rate = has_flat_rate
470
+
471
+ # Mark Task as changed.
472
+ changed
473
+ end
474
+
475
+ # Get the actual consumed budge.
476
+ def consumed_budge
477
+ if @hourly_rate
478
+ duration.to_i.to_f / 3600.0 * @hourly_rate
479
+ else
480
+ 0.0
481
+ end
482
+ end
483
+
484
+ # Calculate the budge based on estimation.
485
+ def estimated_budge
486
+ if @hourly_rate
487
+ estimation.to_i.to_f / 3600.0 * @hourly_rate
488
+ else
489
+ 0.0
490
+ end
491
+ end
492
+
493
+ # Calculates the budge loss when a Flat Rate is used and the consumed duration is greater than the estimation.
494
+ def loss_budge
495
+ if @has_flat_rate && @hourly_rate
496
+ if duration > estimation
497
+ (duration - estimation).to_i.to_f / 3600.0 * @hourly_rate
498
+ else
499
+ 0.0
500
+ end
501
+ else
502
+ 0.0
503
+ end
504
+ end
505
+
506
+ # Start a new Track by given `options`.
507
+ #
508
+ # Options:
509
+ #
510
+ # - `:foreign_id` (String)
511
+ # - `:track_id` (String)
512
+ # - `:no_stop` (Boolean)
513
+ def start(options = Hash.new)
514
+ foreign_id_opt = options.fetch(:foreign_id, nil)
515
+ track_id_opt = options.fetch(:track_id, nil)
516
+
517
+ # Used by Push.
518
+ no_stop_opt = options.fetch(:no_stop, false)
519
+
520
+ unless no_stop_opt
521
+ # End current Track before starting a new one.
522
+ # Leave options empty here for stop().
523
+ stop
524
+ end
525
+
526
+ if foreign_id_opt && @foreign_id.nil?
527
+ @foreign_id = foreign_id_opt
528
+ end
529
+
530
+ if track_id_opt
531
+ found_track = find_track_by_id(track_id_opt)
532
+ if found_track
533
+
534
+ @current_track = found_track.dup
535
+ else
536
+ raise TrackError, "No Track found for Track ID '#{track_id_opt}'."
537
+ end
538
+ else
539
+ @current_track = Track.new
540
+ @current_track.task = self
541
+ end
542
+
543
+ @tracks[@current_track.id] = @current_track
544
+
545
+ # Mark Task as changed.
546
+ changed
547
+
548
+ @current_track.start(options)
549
+ @current_track
550
+ end
551
+
552
+ # Stops a current running Track.
553
+ def stop(options = Hash.new)
554
+ if @current_track
555
+ @current_track.stop(options)
556
+
557
+ # Reset current Track variable.
558
+ @current_track = nil
559
+
560
+ # Mark Task as changed.
561
+ changed
562
+ end
563
+
564
+ nil
565
+ end
566
+
567
+ # Pauses a current running Track.
568
+ def pause(options = Hash.new)
569
+ if @current_track
570
+ @current_track.stop(options)
571
+
572
+ # Mark Task as changed.
573
+ changed
574
+
575
+ @current_track
576
+ end
577
+ end
578
+
579
+ # Continues the current Track.
580
+ # Only if it isn't already running.
581
+ def continue(options = Hash.new)
582
+ track_opt = options.fetch(:track, nil)
583
+
584
+ if @current_track
585
+ if @current_track.stopped?
586
+
587
+ # Duplicate and start.
588
+ @current_track = @current_track.dup
589
+ @current_track.start(options)
590
+
591
+
592
+ add_track(@current_track)
593
+ else
594
+ raise TrackError, "Cannot continue Track #{@current_track.short_id}, is already running."
595
+ end
596
+ else
597
+ unless track_opt
598
+ raise TaskError, 'No Track given.'
599
+ end
600
+
601
+ # Duplicate and start.
602
+ @current_track = track_opt.dup
603
+ @current_track.start(options)
604
+
605
+ add_track(@current_track)
606
+ end
607
+
608
+ @current_track
609
+ end
610
+
611
+ # Consumed duration.
612
+ #
613
+ # Options:
614
+ #
615
+ # - `:billed`
616
+ def duration(options = Hash.new)
617
+
618
+ billed_opt = options.fetch(:billed, nil)
619
+
620
+ duration = Duration.new
621
+ @tracks.each do |track_id, track|
622
+ if billed_opt.nil? || (billed_opt && track.is_billed) || (!billed_opt && !track.is_billed)
623
+ duration += track.duration(options)
624
+ end
625
+ end
626
+ duration
627
+ end
628
+
629
+ # Alias for `duration` method.
630
+ #
631
+ # Options:
632
+ #
633
+ # - `:billed` (Boolean)
634
+ def billed_duration(options = Hash.new)
635
+ duration(options.merge({:billed => true}))
636
+ end
637
+
638
+ # Alias for `duration` method.
639
+ #
640
+ # Options:
641
+ #
642
+ # - `:billed` (Boolean)
643
+ def unbilled_duration(options = Hash.new)
644
+ duration(options.merge({:billed => false}))
645
+ end
646
+
647
+ # Get the remaining Time of estimation.
648
+ #
649
+ # Returns a Duration instance.
650
+ def remaining_time
651
+ if @estimation
652
+ estimation - duration
653
+ end
654
+ end
655
+
656
+ # Get the remaining Time as Human String.
657
+ #
658
+ # - Like `2h 30m`.
659
+ # - Or `---` when `@estimation` is `nil`.
660
+ def remaining_time_s
661
+ rmt = remaining_time
662
+ if rmt
663
+ rmt.to_human
664
+ else
665
+ '---'
666
+ end
667
+ end
668
+
669
+ # Get the remaining Time as percent.
670
+ def remaining_time_percent
671
+ rmt = remaining_time
672
+ if rmt && @estimation
673
+ (rmt.to_i.to_f / @estimation.to_i.to_f) * 100.0
674
+ end
675
+ end
676
+
677
+ # Get the remaining Time Percent as String.
678
+ def remaining_time_percent_s
679
+ rmtp = remaining_time_percent
680
+ if rmtp
681
+ '%.1f %%' % [rmtp]
682
+ else
683
+ '---'
684
+ end
685
+ end
686
+
687
+ # Get Task status as Status instance.
688
+ def status
689
+ stati = @tracks.map{ |track_id, track| track.status.short_status }.to_set
690
+
691
+ if @tracks.count == 0
692
+ status = ?-
693
+ elsif stati.include?(?R)
694
+ status = ?R
695
+ elsif stati.include?(?S)
696
+ status = ?S
697
+ else
698
+ status = ?U
699
+ end
700
+
701
+ Status.new(status)
702
+ end
703
+
704
+ # Set is_billed.
705
+ def is_billed=(is_billed)
706
+ @tracks.each do |track_id, track|
707
+ track.is_billed = is_billed
708
+ end
709
+ end
710
+
711
+ # Find a Track by ID even if the ID is not 40 characters long.
712
+ # When the ID is 40 characters long `@tracks[id]` is faster. ;)
713
+ def find_track_by_id(track_id)
714
+ track_id_len = track_id.length
715
+
716
+ # puts "search track id '#{track_id}'"
717
+
718
+ if track_id_len < 40
719
+ found_track_id = nil
720
+ @tracks.keys.each do |key|
721
+ if track_id == key[0, track_id_len]
722
+ if found_track_id
723
+ raise TrackError, "Track ID '#{track_id}' is not a unique identifier."
724
+ else
725
+ found_track_id = key
726
+
727
+ # Do not break the loop here.
728
+ # Iterate all keys to make sure the ID is unique.
729
+ end
730
+ end
731
+ end
732
+ track_id = found_track_id
733
+ end
734
+
735
+ @tracks[track_id]
736
+ end
737
+
738
+ # Are two Tasks equal?
739
+ #
740
+ # Uses ID for comparision.
741
+ def eql?(task)
742
+ unless task.is_a?(Task)
743
+ raise TaskError, "task variable must be a Task instance. #{task.class} given."
744
+ end
745
+
746
+ self.id == task.id
747
+ end
748
+
749
+ # To String
750
+ def to_s
751
+ "Task_#{short_id}"
752
+ end
753
+
754
+ # Use to print informations for Track.
755
+ #
756
+ # Options:
757
+ #
758
+ # - `:full_id` (Boolean) Show full Task ID.
759
+ def to_track_array(options = Hash.new)
760
+ full_id_opt = options.fetch(:full_id, false) # @TODO full_id unit test
761
+
762
+ full_id = full_id_opt ? self.id : self.short_id
763
+
764
+ name_a = ["Task: #{full_id}"]
765
+
766
+ if self.foreign_id
767
+ name_a << self.foreign_id
768
+ end
769
+ if self.name
770
+ name_a << self.name
771
+ end
772
+
773
+ to_ax = Array.new
774
+ to_ax << name_a.join(' ')
775
+ to_ax
776
+ end
777
+
778
+ # Used to print informations to STDOUT.
779
+ def to_compact_str
780
+ to_compact_array.join("\n")
781
+ end
782
+
783
+ # Used to print informations to STDOUT.
784
+ def to_compact_array
785
+ to_ax = Array.new
786
+ if self.foreign_id
787
+ to_ax << 'Task: %s %s %s' % [self.short_id, self.foreign_id, self.name]
788
+ else
789
+ to_ax << 'Task: %s %s' % [self.short_id, self.name]
790
+ end
791
+ if self.description
792
+ to_ax << 'Description: %s' % [self.description]
793
+ end
794
+ if self.estimation
795
+ to_ax << 'Estimation: %s' % [self.estimation.to_human]
796
+ end
797
+ to_ax
798
+ end
799
+
800
+ # Used to print informations to STDOUT.
801
+ def to_detailed_str
802
+ to_detailed_array.join("\n")
803
+ end
804
+
805
+ # Used to print informations to STDOUT.
806
+ #
807
+ # Options:
808
+ #
809
+ # - `:full_id` (Boolean) Show full Task ID.
810
+ def to_detailed_array(options = Hash.new)
811
+ full_id_opt = options.fetch(:full_id, false)
812
+
813
+ to_ax = Array.new
814
+
815
+ if full_id_opt
816
+ to_ax << 'Task: %s' % [self.id]
817
+ else
818
+ to_ax << 'Task: %s' % [self.short_id]
819
+ end
820
+
821
+ if self.foreign_id
822
+ to_ax << 'Foreign ID: %s' % [self.foreign_id]
823
+ end
824
+
825
+ to_ax << 'Name: %s' % [self.name]
826
+
827
+ if self.description
828
+ to_ax << 'Description: %s' % [self.description]
829
+ end
830
+
831
+ # Duration
832
+ duration_human = self.duration.to_human
833
+ to_ax << 'Duration: %s' % [duration_human]
834
+
835
+ duration_man_days = self.duration.to_man_days
836
+ if duration_human != duration_man_days
837
+ to_ax << 'Man Unit: %s' % [duration_man_days]
838
+ end
839
+
840
+ # Billed Duration
841
+ billed_duration_human = self.billed_duration.to_human
842
+ to_ax << 'Billed Duration: %s' % [billed_duration_human]
843
+
844
+ # Unbilled Duration
845
+ unbilled_duration_human = self.unbilled_duration.to_human
846
+ to_ax << 'Unbilled Duration: %s' % [unbilled_duration_human]
847
+
848
+ if self.estimation
849
+ to_ax << 'Estimation: %s' % [self.estimation.to_human]
850
+
851
+ to_ax << 'Time Remaining: %s (%s)' % [self.remaining_time_s, self.remaining_time_percent_s]
852
+
853
+ bar_options = {
854
+ :total => self.estimation.to_i,
855
+ :progress => self.duration.to_i,
856
+ :length => 50,
857
+ :progress_mark => '#',
858
+ :remainder_mark => '-',
859
+ }
860
+ bar = ProgressBar.new(bar_options)
861
+
862
+ to_ax << ' |%s|' % [bar.render]
863
+ end
864
+
865
+ if self.hourly_rate
866
+ to_ax << 'Hourly Rate: %.2f' % [self.hourly_rate]
867
+ to_ax << 'Flat Rate: %s' % [@has_flat_rate ? 'Yes' : 'No']
868
+
869
+ to_ax << 'Consumed Budge: %.2f' % [self.consumed_budge]
870
+
871
+ if self.estimation
872
+ to_ax << 'Estimated Budge: %.2f' % [self.estimated_budge]
873
+ end
874
+
875
+ if @has_flat_rate
876
+ to_ax << 'Loss Budge: %.2f' % [self.loss_budge]
877
+ end
878
+ end
879
+
880
+ tracks = self.tracks
881
+ first_track = tracks
882
+ .select{ |track_id, track| track.begin_datetime }
883
+ .sort_by{ |track_id, track| track.begin_datetime }
884
+ .to_h
885
+ .values
886
+ .first
887
+ if first_track
888
+ to_ax << 'Begin Track: %s %s' % [first_track.short_id, first_track.begin_datetime_s]
889
+ end
890
+
891
+ last_track = tracks
892
+ .select{ |track_id, track| track.end_datetime }
893
+ .sort_by{ |track_id, track| track.end_datetime }
894
+ .to_h
895
+ .values
896
+ .last
897
+ if last_track
898
+ to_ax << 'End Track: %s %s' % [last_track.short_id, last_track.end_datetime_s]
899
+ end
900
+
901
+ status = self.status.colorized
902
+ to_ax << 'Status: %s' % [status]
903
+
904
+ if @current_track
905
+ to_ax << 'Current Track: %s %s' % [@current_track.short_id, @current_track.title]
906
+ end
907
+
908
+ tracks_count = tracks.count
909
+ to_ax << 'Tracks: %d' % [tracks_count]
910
+
911
+ billed_tracks_count = tracks({:billed => true}).count
912
+ to_ax << 'Billed Tracks: %d' % [billed_tracks_count]
913
+
914
+ unbilled_tracks_count = tracks({:billed => false}).count
915
+ to_ax << 'Unbilled Tracks: %d' % [unbilled_tracks_count]
916
+
917
+ if tracks_count > 0 && @tracks_opt # --tracks
918
+ to_ax << 'Track IDs: %s' % [tracks.map{ |track_id, track| track.short_id }.join(' ')]
919
+ end
920
+
921
+ if self.file_path
922
+ to_ax << 'File path: %s' % [self.file_path]
923
+ end
924
+
925
+ to_ax
926
+ end
927
+
928
+ def inspect
929
+ "#<Task_#{short_id} tracks=#{@tracks.count}>"
930
+ end
931
+
932
+ # All methods in this block are static.
933
+ class << self
934
+
935
+ # Load a Task from a file into a Task instance.
936
+ def load_task_from_file(path)
937
+ task = Task.new
938
+ task.load_from_file(path)
939
+ task
940
+ end
941
+
942
+ # Search a Task in a base path for a Track by ID.
943
+ # If found a file load it into a Task instance.
944
+ def load_task_from_file_with_id(base_path, task_id)
945
+ task_file_path = BasicModel.find_file_by_id(base_path, task_id)
946
+ if task_file_path
947
+ load_task_from_file(task_file_path)
948
+ end
949
+ end
950
+
951
+ # Create a new Task using a Hash.
952
+ #
953
+ # Options:
954
+ #
955
+ # - `:name` (String)
956
+ # - `:description` (String)
957
+ # - `:estimation` (String|Integer|Duration)
958
+ # - `:hourly_rate` (Integer)
959
+ def create_task_from_hash(options)
960
+ task = Task.new
961
+ task.name = options.fetch(:name, nil)
962
+ task.description = options.fetch(:description, nil)
963
+ task.estimation = options.fetch(:estimation, nil)
964
+ task.hourly_rate = options.fetch(:hourly_rate, nil)
965
+ task.has_flat_rate = options.fetch(:has_flat_rate, false)
966
+ task
967
+ end
968
+
969
+ end
970
+
971
+ private
972
+
973
+ # BasicModel Hook
974
+ def pre_save_to_file
975
+ # Meta
976
+ @meta['foreign_id'] = @foreign_id
977
+ @meta['name'] = @name
978
+ @meta['description'] = @description
979
+
980
+ @meta['current_track_id'] = nil
981
+ if @current_track
982
+ @meta['current_track_id'] = @current_track.id
983
+ end
984
+
985
+ if @estimation
986
+ @meta['estimation'] = @estimation.to_i
987
+ end
988
+ if @hourly_rate
989
+ @meta['hourly_rate'] = @hourly_rate.to_f
990
+ else
991
+ @meta['hourly_rate'] = nil
992
+ end
993
+ if @has_flat_rate
994
+ @meta['has_flat_rate'] = @has_flat_rate
995
+ else
996
+ @meta['has_flat_rate'] = false
997
+ end
998
+
999
+ # Tracks
1000
+ @data = @tracks.map{ |track_id, track|
1001
+ [track_id, track.to_h]
1002
+ }.to_h
1003
+
1004
+ super()
1005
+ end
1006
+
1007
+ # BasicModel Hook
1008
+ def post_load_from_file
1009
+ @tracks = @data.map{ |track_id, track_h|
1010
+ track = Track.create_track_from_hash(track_h)
1011
+ track.task = self
1012
+ [track_id, track]
1013
+ }.to_h
1014
+
1015
+ current_track_id = @meta['current_track_id']
1016
+ if current_track_id
1017
+ @current_track = @tracks[current_track_id]
1018
+ end
1019
+
1020
+ @foreign_id = @meta['foreign_id']
1021
+ @name = @meta['name']
1022
+ @description = @meta['description']
1023
+
1024
+ if @meta['estimation']
1025
+ @estimation = Duration.new(@meta['estimation'])
1026
+ end
1027
+ if @meta['hourly_rate']
1028
+ @hourly_rate = @meta['hourly_rate'].to_f
1029
+ end
1030
+ if @meta['has_flat_rate']
1031
+ @has_flat_rate = @meta['has_flat_rate']
1032
+ end
1033
+ end
1034
+
1035
+ end # class Task
1036
+
1037
+ end # module Model
1038
+ end # module Timr
1039
+ end #module TheFox