timr 0.3.0 → 0.4.0

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