timerizer 0.2.1 → 0.3.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.
@@ -0,0 +1,3 @@
1
+ require_relative "./duration"
2
+ require_relative "./wall_clock"
3
+ require_relative "./version"
@@ -0,0 +1,766 @@
1
+ module Timerizer
2
+ # Represents a duration of time. For example, '5 days', '4 years', and
3
+ # '5 years, 4 hours, 3 minutes, 2 seconds' are all durations conceptually.
4
+ #
5
+ # A `Duration` is made up of two different primitive units: seconds and
6
+ # months. The philosphy behind this is this: every duration of time
7
+ # can be broken down into these fundamental pieces, but cannot be simplified
8
+ # further. For example, 1 year always equals 12 months, 1 minute always
9
+ # equals 60 seconds, but 1 month does not always equal 30 days. This
10
+ # ignores some important corner cases (such as leap seconds), but this
11
+ # philosophy should be "good enough" for most use-cases.
12
+ #
13
+ # This extra divide between "seconds" and "months" may seem useless or
14
+ # conter-intuitive at first, but can be useful when applying durations to
15
+ # times. For example, `1.year.after(Time.new(2000, 1, 1))` is guaranteed
16
+ # to return `Time.new(2001, 1, 1)`, which would not be possible if all
17
+ # durations were represented in seconds alone.
18
+ #
19
+ # On top of that, even though 1 month cannot be _exactly_ represented as a
20
+ # certain number of days, it's still useful to often convert between durations
21
+ # made of different base units, especially when converting a `Duration` to a
22
+ # human-readable format. This is the reason for the {#normalize} and
23
+ # {#denormalize} methods. For convenience, most methods perform normalization
24
+ # on the input duration, so that some results or comparisons give more
25
+ # intuitive values.
26
+ class Duration
27
+ include Comparable
28
+
29
+ # A hash describing the different base units of a `Duration`. Key represent
30
+ # unit names and values represent a hash describing the scale of that unit.
31
+ UNITS = {
32
+ seconds: {seconds: 1},
33
+ minutes: {seconds: 60},
34
+ hours: {seconds: 60 * 60},
35
+ days: {seconds: 24 * 60 * 60},
36
+ weeks: {seconds: 7 * 24 * 60 * 60},
37
+ months: {months: 1},
38
+ years: {months: 12},
39
+ decades: {months: 12 * 10},
40
+ centuries: {months: 12 * 100},
41
+ millennia: {months: 12 * 1000}
42
+ }
43
+
44
+ # A hash describing different names for various units, which allows for,
45
+ # e.g., pluralized unit names, or more obscure units. `UNIT_ALIASES` is
46
+ # guaranteed to also contain all of the entries from {UNITS}.
47
+ UNIT_ALIASES = UNITS.merge(
48
+ second: UNITS[:seconds],
49
+ minute: UNITS[:minutes],
50
+ hour: UNITS[:hours],
51
+ day: UNITS[:days],
52
+ week: UNITS[:weeks],
53
+ month: UNITS[:months],
54
+ year: UNITS[:years],
55
+ decade: UNITS[:decades],
56
+ century: UNITS[:centuries],
57
+ millennium: UNITS[:millennia]
58
+ )
59
+
60
+ # The built-in set of normalization methods, usable with {#normalize} and
61
+ # {#denormalize}. Keys are method names, and values are hashes describing
62
+ # how units are normalized or denormalized.
63
+ #
64
+ # The following normalization methods are defined:
65
+ #
66
+ # - `:standard`: 1 month is approximated as 30 days, and 1 year is
67
+ # approximated as 365 days.
68
+ # - `:minimum`: 1 month is approximated as 28 days (the minimum in any
69
+ # month), and 1 year is approximated as 365 days (the minimum in any
70
+ # year).
71
+ # - `:maximum`: 1 month is approximated as 31 days (the maximum in any
72
+ # month), and 1 year is approximated as 366 days (the maximum in any
73
+ # year).
74
+ NORMALIZATION_METHODS = {
75
+ standard: {
76
+ months: {seconds: 30 * 24 * 60 * 60},
77
+ years: {seconds: 365 * 24 * 60 * 60}
78
+ },
79
+ minimum: {
80
+ months: {seconds: 28 * 24 * 60 * 60},
81
+ years: {seconds: 365 * 24 * 60 * 60}
82
+ },
83
+ maximum: {
84
+ months: {seconds: 31 * 24 * 60 * 60},
85
+ years: {seconds: 366 * 24 * 60 * 60}
86
+ }
87
+ }
88
+
89
+ # The built-in formats that can be used with {#to_s}.
90
+ #
91
+ # The following string formats are defined:
92
+ #
93
+ # - `:long`: The default, long-form string format. Example string:
94
+ # `"1 year, 2 months, 3 weeks, 4 days, 5 hours"`.
95
+ # - `:short`: A shorter format, which includes 2 significant units by
96
+ # default. Example string: `"1mo 2d"`
97
+ # - `:micro`: A very terse format, which includes only one significant unit
98
+ # by default. Example string: `"1h"`
99
+ FORMATS = {
100
+ micro: {
101
+ units: {
102
+ seconds: 's',
103
+ minutes: 'm',
104
+ hours: 'h',
105
+ days: 'd',
106
+ weeks: 'w',
107
+ months: 'mo',
108
+ years: 'y',
109
+ },
110
+ separator: '',
111
+ delimiter: ' ',
112
+ count: 1
113
+ },
114
+ short: {
115
+ units: {
116
+ seconds: 'sec',
117
+ minutes: 'min',
118
+ hours: 'hr',
119
+ days: 'd',
120
+ weeks: 'wk',
121
+ months: 'mo',
122
+ years: 'yr'
123
+ },
124
+ separator: '',
125
+ delimiter: ' ',
126
+ count: 2
127
+ },
128
+ long: {
129
+ units: {
130
+ seconds: ['second', 'seconds'],
131
+ minutes: ['minute', 'minutes'],
132
+ hours: ['hour', 'hours'],
133
+ days: ['day', 'days'],
134
+ weeks: ['week', 'weeks'],
135
+ months: ['month', 'months'],
136
+ years: ['year', 'years']
137
+ }
138
+ }
139
+ }
140
+
141
+ # Initialize a new instance of {Duration}.
142
+ #
143
+ # @param [Hash<Symbol, Integer>] units A hash that maps from unit names
144
+ # to the quantity of that unit. See the keys of {UNIT_ALIASES} for
145
+ # a list of valid unit names.
146
+ #
147
+ # @example
148
+ # Timerizer::Duration.new(years: 4, months: 2, hours: 12, minutes: 60)
149
+ def initialize(units = {})
150
+ @seconds = 0
151
+ @months = 0
152
+
153
+ units.each do |unit, n|
154
+ unit_info = self.class.resolve_unit(unit)
155
+ @seconds += n * unit_info.fetch(:seconds, 0)
156
+ @months += n * unit_info.fetch(:months, 0)
157
+ end
158
+ end
159
+
160
+ # Return the number of "base" units in a {Duration}. Note that this method
161
+ # is a lower-level method, and will not be needed by most users. See
162
+ # {#to_unit} for a more general equivalent.
163
+ #
164
+ # @param [Symbol] unit The base unit to return, either
165
+ # `:seconds` or `:months`.
166
+ #
167
+ # @return [Integer] The requested unit count. Note that this method does
168
+ # not perform normalization first, so results may not be intuitive.
169
+ #
170
+ # @raise [ArgumentError] The unit requested was not `:seconds` or `:months`.
171
+ #
172
+ # @see #to_unit
173
+ def get(unit)
174
+ if unit == :seconds
175
+ @seconds
176
+ elsif unit == :months
177
+ @months
178
+ else
179
+ raise ArgumentError
180
+ end
181
+ end
182
+
183
+ # Returns the time `self` earlier than the given time.
184
+ #
185
+ # @param [Time] time The initial time.
186
+ # @return [Time] The time before this {Duration} has elapsed past the
187
+ # given time.
188
+ #
189
+ # @example 5 minutes before January 1st, 2000 at noon
190
+ # 5.minutes.before(Time.new(2000, 1, 1, 12, 00, 00))
191
+ # # => 2000-01-01 11:55:00 -0800
192
+ #
193
+ # @see #ago
194
+ # @see #after
195
+ # @see #from_now
196
+ def before(time)
197
+ (-self).after(time)
198
+ end
199
+
200
+ # Return the time `self` later than the current time.
201
+ #
202
+ # @return [Time] The time after this {Duration} has elapsed past the
203
+ # current system time.
204
+ #
205
+ # @see #before
206
+ def ago
207
+ self.before(Time.now)
208
+ end
209
+
210
+ # Returns the time `self` later than the given time.
211
+ #
212
+ # @param [Time] time The initial time.
213
+ # @return [Time] The time after this {Duration} has elapsed past the
214
+ # given time.
215
+ #
216
+ # @example 5 minutes after January 1st, 2000 at noon
217
+ # 5.minutes.after(Time.new(2000, 1, 1, 12, 00, 00))
218
+ # # => 2000-01-01 12:05:00 -0800
219
+ #
220
+ # @see #ago
221
+ # @see #before
222
+ # @see #from_now
223
+ def after(time)
224
+ time = time.to_time
225
+
226
+ prev_day = time.mday
227
+ prev_month = time.month
228
+ prev_year = time.year
229
+
230
+ units = self.to_units(:years, :months, :days, :seconds)
231
+
232
+ date_in_month = self.class.build_date(
233
+ prev_year + units[:years],
234
+ prev_month + units[:months],
235
+ prev_day
236
+ )
237
+ date = date_in_month + units[:days]
238
+
239
+ Time.new(
240
+ date.year,
241
+ date.month,
242
+ date.day,
243
+ time.hour,
244
+ time.min,
245
+ time.sec
246
+ ) + units[:seconds]
247
+ end
248
+
249
+ # Return the time `self` earlier than the current time.
250
+ #
251
+ # @return [Time] The time current system time before this {Duration}.
252
+ #
253
+ # @see #before
254
+ def from_now
255
+ self.after(Time.now)
256
+ end
257
+
258
+ # Convert the duration to a given unit.
259
+ #
260
+ # @param [Symbol] unit The unit to convert to. See {UNIT_ALIASES} for a list
261
+ # of valid unit names.
262
+ #
263
+ # @return [Integer] The quantity of the given unit present in `self`. Note
264
+ # that, if `self` cannot be represented exactly by `unit`, then the result
265
+ # will be truncated (rounded toward 0 instead of rounding down, unlike
266
+ # normal Ruby integer division).
267
+ #
268
+ # @raise ArgumentError if the given unit could not be resolved.
269
+ #
270
+ # @example
271
+ # 1.hour.to_unit(:minutes)
272
+ # # => 60
273
+ # 121.seconds.to_unit(:minutes)
274
+ # # => 2
275
+ #
276
+ # @note The duration is normalized or denormalized first, depending on the
277
+ # unit requested. This means that, by default, the returned unit will
278
+ # be an approximation if it cannot be represented exactly by the duration,
279
+ # such as when converting a duration of months to seconds, or vice versa.
280
+ #
281
+ # @see #to_units
282
+ def to_unit(unit)
283
+ unit_details = self.class.resolve_unit(unit)
284
+
285
+ if unit_details.has_key?(:seconds)
286
+ seconds = self.normalize.get(:seconds)
287
+ self.class.div(seconds, unit_details.fetch(:seconds))
288
+ elsif unit_details.has_key?(:months)
289
+ months = self.denormalize.get(:months)
290
+ self.class.div(months, unit_details.fetch(:months))
291
+ else
292
+ raise "Unit should have key :seconds or :months"
293
+ end
294
+ end
295
+
296
+ # Convert the duration to a hash of units. For each given unit argument,
297
+ # the returned hash will map the unit to the quantity of that unit present
298
+ # in the duration. Each returned unit will be truncated to an integer, and
299
+ # the remainder will "carry" to the next unit down. The resulting hash can
300
+ # be passed to {Duration#initialize} to get the same result, so this method
301
+ # can be thought of as the inverse of {Duration#initialize}.
302
+ #
303
+ # @param [Array<Symbol>] units The units to convert to. Each unit
304
+ # will correspond with a key in the returned hash.
305
+ #
306
+ # @return [Hash<Symbol, Integer>] A hash mapping each unit to the quantity
307
+ # of that unit. Note that whether the returned unit is plural, or uses
308
+ # an alias, depends on what unit was passed in as an argument.
309
+ #
310
+ # @note The duration may be normalized or denormalized first, depending
311
+ # on the units requested. This behavior is identical to {#to_unit}.
312
+ #
313
+ # @example
314
+ # 121.seconds.to_units(:minutes)
315
+ # # => {minutes: 2}
316
+ # 121.seconds.to_units(:minutes, :seconds)
317
+ # # => {minutes: 2, seconds: 1}
318
+ # 1.year.to_units(:days)
319
+ # # => {days: 365}
320
+ # (91.days 12.hours).to_units(:months, :hours)
321
+ # # => {months: 3, hours: 36}
322
+ def to_units(*units)
323
+ sorted_units = self.class.sort_units(units).reverse
324
+
325
+ _, parts = sorted_units.reduce([self, {}]) do |(remainder, parts), unit|
326
+ part = remainder.to_unit(unit)
327
+ new_remainder = remainder - Duration.new(unit => part)
328
+
329
+ [new_remainder, parts.merge(unit => part)]
330
+ end
331
+
332
+ parts
333
+ end
334
+
335
+ # Return a new duration that approximates the given input duration, where
336
+ # every "month-based" unit of the input is converted to seconds. Because
337
+ # durations are composed of two distinct units ("seconds" and "months"),
338
+ # two durations need to be normalized before being compared. By default,
339
+ # most methods on {Duration} perform normalization or denormalization, so
340
+ # clients will not usually need to call this method directly.
341
+ #
342
+ # @param [Symbol] method The normalization method to be used. For a list
343
+ # of normalization methods, see {NORMALIZATION_METHODS}.
344
+ #
345
+ # @return [Duration] The duration after being normalized.
346
+ #
347
+ # @example
348
+ # 1.month.normalize == 30.days
349
+ # 1.month.normalize(method: :standard) == 30.days
350
+ # 1.month.normalize(method: :maximum) == 31.days
351
+ # 1.month.normalize(method: :minimum) == 28.days
352
+ #
353
+ # 1.year.normalize == 365.days
354
+ # 1.year.normalize(method: :standard) == 365.days
355
+ # 1.year.normalize(method: :minimum) == 365.days
356
+ # 1.year.normalize(method: :maximum) == 366.days
357
+ #
358
+ # @see #denormalize
359
+ def normalize(method: :standard)
360
+ normalized_units = NORMALIZATION_METHODS.fetch(method).reverse_each
361
+
362
+ initial = [0.seconds, self]
363
+ result = normalized_units.reduce(initial) do |result, (unit, normal)|
364
+ normalized, remainder = result
365
+
366
+ seconds_per_unit = normal.fetch(:seconds)
367
+ unit_part = remainder.send(:to_unit_part, unit)
368
+
369
+ normalized += (unit_part * seconds_per_unit).seconds
370
+ remainder -= Duration.new(unit => unit_part)
371
+ [normalized, remainder]
372
+ end
373
+
374
+ normalized, remainder = result
375
+ normalized + remainder
376
+ end
377
+
378
+ # Return a new duration that inverts an approximation made by {#normalize}.
379
+ # Denormalization results in a {Duration} where "second-based" units are
380
+ # converted back to "month-based" units. Note that, due to the lossy nature
381
+ # {#normalize}, the result of calling {#normalize} then {#denormalize} may
382
+ # result in a {Duration} that is _not_ equal to the input.
383
+ #
384
+ # @param [Symbol] method The normalization method to invert. For a list of
385
+ # normalization methods, see {NORMALIZATION_METHODS}.
386
+ #
387
+ # @return [Duration] The duration after being denormalized.
388
+ #
389
+ # @example
390
+ # 30.days.denormalize == 1.month
391
+ # 30.days.denormalize(method: :standard) == 1.month
392
+ # 28.days.denormalize(method: :minimum) == 1.month
393
+ # 31.days.denormalize(method: :maximum) == 1.month
394
+ #
395
+ # 365.days.denormalize == 1.year
396
+ # 365.days.denormalize(method: :standard) == 1.year
397
+ # 365.days.denormalize(method: :minimum) == 1.year
398
+ # 366.days.denormalize(method: :maximum) == 1.year
399
+ def denormalize(method: :standard)
400
+ normalized_units = NORMALIZATION_METHODS.fetch(method).reverse_each
401
+
402
+ initial = [0.seconds, self]
403
+ result = normalized_units.reduce(initial) do |result, (unit, normal)|
404
+ denormalized, remainder = result
405
+
406
+ seconds_per_unit = normal.fetch(:seconds)
407
+ remainder_seconds = remainder.get(:seconds)
408
+
409
+ num_unit = self.class.div(remainder_seconds, seconds_per_unit)
410
+ num_seconds_denormalized = num_unit * seconds_per_unit
411
+
412
+ denormalized += Duration.new(unit => num_unit)
413
+ remainder -= num_seconds_denormalized.seconds
414
+
415
+ [denormalized, remainder]
416
+ end
417
+
418
+ denormalized, remainder = result
419
+ denormalized + remainder
420
+ end
421
+
422
+ # Compare two duartions. Note that durations are compared after
423
+ # normalization.
424
+ #
425
+ # @param [Duration] other The duration to compare.
426
+ #
427
+ # @return [Integer, nil] 0 if the durations are equal, -1 if the left-hand
428
+ # side is greater, +1 if the right-hand side is greater. Returns `nil` if
429
+ # the duration cannot be compared ot `other`.
430
+ def <=>(other)
431
+ case other
432
+ when Duration
433
+ self.to_unit(:seconds) <=> other.to_unit(:seconds)
434
+ else
435
+ nil
436
+ end
437
+ end
438
+
439
+ # Negates a duration.
440
+ #
441
+ # @return [Duration] A new duration where each component was negated.
442
+ def -@
443
+ Duration.new(seconds: -@seconds, months: -@months)
444
+ end
445
+
446
+ # @overload +(duration)
447
+ # Add together two durations.
448
+ #
449
+ # @param [Duration] duration The duration to add.
450
+ #
451
+ # @return [Duration] The resulting duration with each component added
452
+ # to the input duration.
453
+ #
454
+ # @example
455
+ # 1.day + 1.hour == 25.hours
456
+ #
457
+ # @overload +(time)
458
+ # Add a time to a duration, returning a new time.
459
+ #
460
+ # @param [Time] time The time to add this duration to.
461
+ #
462
+ # @return [Time] The time after the duration has elapsed.
463
+ #
464
+ # @example
465
+ # 1.day + Time.new(2000, 1, 1) == Time.new(2000, 1, 2)
466
+ #
467
+ # @see #after
468
+ def +(other)
469
+ case other
470
+ when 0
471
+ self
472
+ when Duration
473
+ Duration.new(
474
+ seconds: @seconds + other.get(:seconds),
475
+ months: @months + other.get(:months)
476
+ )
477
+ when Time
478
+ self.after(other)
479
+ else
480
+ raise ArgumentError, "Cannot add #{other.inspect} to Duration #{self}"
481
+ end
482
+ end
483
+
484
+ # Subtract two durations.
485
+ #
486
+ # @param [Duration] other The duration to subtract.
487
+ #
488
+ # @return [Duration] The resulting duration with each component subtracted
489
+ # from the input duration.
490
+ #
491
+ # @example
492
+ # 1.day - 1.hour == 23.hours
493
+ def -(other)
494
+ case other
495
+ when 0
496
+ self
497
+ when Duration
498
+ Duration.new(
499
+ seconds: @seconds - other.get(:seconds),
500
+ months: @months - other.get(:months)
501
+ )
502
+ else
503
+ raise ArgumentError, "Cannot subtract #{other.inspect} from Duration #{self}"
504
+ end
505
+ end
506
+
507
+ # Multiply a duration by a scalar.
508
+ #
509
+ # @param [Integer] other The scalar to multiply by.
510
+ #
511
+ # @return [Duration] The resulting duration with each component multiplied
512
+ # by the scalar.
513
+ #
514
+ # @example
515
+ # 1.day * 7 == 1.week
516
+ def *(other)
517
+ case other
518
+ when Integer
519
+ Duration.new(
520
+ seconds: @seconds * other,
521
+ months: @months * other
522
+ )
523
+ else
524
+ raise ArgumentError, "Cannot multiply Duration #{self} by #{other.inspect}"
525
+ end
526
+ end
527
+
528
+ # Divide a duration by a scalar.
529
+ #
530
+ # @param [Integer] other The scalar to divide by.
531
+ #
532
+ # @return [Duration] The resulting duration with each component divided by
533
+ # the scalar.
534
+ #
535
+ # @note A duration can only be divided by an integer divisor. The resulting
536
+ # duration will have each component divided with integer division, which
537
+ # will result in truncation.
538
+ #
539
+ # @example
540
+ # 1.week / 7 == 1.day
541
+ # 1.second / 2 == 0.seconds # This is a result of truncation
542
+ def /(other)
543
+ case other
544
+ when Integer
545
+ Duration.new(
546
+ seconds: @seconds / other,
547
+ months: @months / other
548
+ )
549
+ else
550
+ raise ArgumentError, "Cannot divide Duration #{self} by #{other.inspect}"
551
+ end
552
+ end
553
+
554
+ # Convert a duration to a {WallClock}.
555
+ #
556
+ # @return [WallClock] `self` as a {WallClock}
557
+ #
558
+ # @example
559
+ # (17.hours 30.minutes).to_wall
560
+ # # => 5:30:00 PM
561
+ def to_wall
562
+ raise WallClock::TimeOutOfBoundsError if @months > 0
563
+ WallClock.new(second: @seconds)
564
+ end
565
+
566
+ # Convert a duration to a human-readable string.
567
+ #
568
+ # @param [Symbol, Hash] format The format type to format the duration with.
569
+ # `format` can either be a key from the {FORMATS} hash or a hash with
570
+ # the same shape as `options`.
571
+ # @param [Hash, nil] options Additional options to use to override default
572
+ # format options.
573
+ #
574
+ # @option options [Hash<Symbol, String>] :units The full list of unit names
575
+ # to use. Keys are unit names (see {UNIT_ALIASES} for a full list) and
576
+ # values are strings to use when converting that unit to a string. Values
577
+ # can also be an array, where the first item of the array will be used
578
+ # for singular unit names and the second item will be used for plural
579
+ # unit names. Note that this option will completely override the input
580
+ # formats' list of names, so all units that should be used must be
581
+ # specified!
582
+ # @option options [String] :separator The separator to use between a unit
583
+ # quantity and the unit's name. For example, the string `"1 second"` uses
584
+ # a separator of `" "`.
585
+ # @option options [String] :delimiter The delimiter to use between separate
586
+ # units. For example, the string `"1 minute, 1 second"` uses a separator
587
+ # of `", "`
588
+ # @option options [Integer, nil, :all] :count The number of significant
589
+ # units to use in the string, or `nil` / `:all` to use all units.
590
+ # For example, if the given duration is `1.day 1.week 1.month`, and
591
+ # `options[:count]` is 2, then the resulting string will only include
592
+ # the month and the week components of the string.
593
+ #
594
+ # @return [String] The duration formatted as a string.
595
+ def to_s(format = :long, options = nil)
596
+ format =
597
+ case format
598
+ when Symbol
599
+ FORMATS.fetch(format)
600
+ when Hash
601
+ FORMATS.fetch(:long).merge(format)
602
+ else
603
+ raise ArgumentError, "Expected #{format.inspect} to be a Symbol or Hash"
604
+ end
605
+
606
+ format = format.merge(options || {})
607
+
608
+ count =
609
+ if format[:count].nil? || format[:count] == :all
610
+ UNITS.count
611
+ else
612
+ format[:count]
613
+ end
614
+
615
+ format_units = format.fetch(:units)
616
+ units = self.to_units(*format_units.keys).select {|unit, n| n > 0}
617
+ if units.empty?
618
+ units = {seconds: 0}
619
+ end
620
+
621
+ separator = format[:separator] || ' '
622
+ delimiter = format[:delimiter] || ', '
623
+ units.take(count).map do |unit, n|
624
+ unit_label = format_units.fetch(unit)
625
+
626
+ singular, plural =
627
+ case unit_label
628
+ when Array
629
+ unit_label
630
+ else
631
+ [unit_label, unit_label]
632
+ end
633
+
634
+ unit_name =
635
+ if n == 1
636
+ singular
637
+ else
638
+ plural || singular
639
+ end
640
+
641
+ [n, unit_name].join(separator)
642
+ end.join(format[:delimiter] || ', ')
643
+ end
644
+
645
+ private
646
+
647
+ # This method is like {#to_unit}, except it does not perform normalization
648
+ # first. Put another way, this method is essentially the same as {#to_unit}
649
+ # except it does not normalize the value first. It is similar to {#get}
650
+ # except that it can be used with non-primitive units as well.
651
+ #
652
+ # @example
653
+ # (1.year 1.month 365.days).to_unit_part(:month)
654
+ # # => 13
655
+ # # Returns 13 because that is the number of months contained exactly
656
+ # # within the sepcified duration. Since "days" cannot be translated
657
+ # # to an exact number of months, they *are not* factored into the result
658
+ # # at all.
659
+ #
660
+ # (25.months).to_unit_part(:year)
661
+ # # => 2
662
+ # # Returns 2 becasue that is the number of months contained exactly
663
+ # # within the specified duration. Since "years" is essentially an alias
664
+ # # for "12 months", months *are* factored into the result.
665
+ def to_unit_part(unit)
666
+ unit_details = self.class.resolve_unit(unit)
667
+
668
+ if unit_details.has_key?(:seconds)
669
+ seconds = self.get(:seconds)
670
+ self.class.div(seconds, unit_details.fetch(:seconds))
671
+ elsif unit_details.has_key?(:months)
672
+ months = self.get(:months)
673
+ self.class.div(months, unit_details.fetch(:months))
674
+ else
675
+ raise "Unit should have key :seconds or :months"
676
+ end
677
+ end
678
+
679
+ def self.resolve_unit(unit)
680
+ UNIT_ALIASES[unit] or raise ArgumentError, "Unknown unit: #{unit.inspect}"
681
+ end
682
+
683
+ def self.sort_units(units)
684
+ units.sort_by do |unit|
685
+ unit_info = self.resolve_unit(unit)
686
+ [unit_info.fetch(:months, 0), unit_info.fetch(:seconds, 0)]
687
+ end
688
+ end
689
+
690
+ def self.mod_div(x, divisor)
691
+ modulo = x % divisor
692
+ [modulo, (x - modulo).to_i / divisor]
693
+ end
694
+
695
+ # Like the normal Ruby division operator, except it rounds towards 0 when
696
+ # dividing `Integer`s (instead of rounding down).
697
+ def self.div(x, divisor)
698
+ (x.to_f / divisor).to_i
699
+ end
700
+
701
+ def self.month_carry(month)
702
+ month_offset, year_carry = self.mod_div(month - 1, 12)
703
+ [month_offset + 1, year_carry]
704
+ end
705
+
706
+ # Create a date from a given year, month, and date. If the month is not in
707
+ # the range `1..12`, then the month will "wrap around", adjusting the given
708
+ # year accordingly (so a year of 2017 and a month of 0 corresponds with
709
+ # 12/2016, a year of 2017 and a month of 13 correpsonds with 1/2018, and so
710
+ # on). If the given day is out of range of the given month, then the
711
+ # date will be nudged back to the last day of the month.
712
+ def self.build_date(year, month, day)
713
+ new_month, year_carry = self.month_carry(month)
714
+ new_year = year + year_carry
715
+
716
+ if Date.valid_date?(new_year, new_month, day)
717
+ Date.new(new_year, new_month, day)
718
+ else
719
+ Date.new(new_year, new_month, -1)
720
+ end
721
+ end
722
+
723
+ # @!macro [attach] define_to_unit
724
+ # @method to_$1
725
+ #
726
+ # Convert the duration to the given unit. This is a helper that
727
+ # is equivalent to calling {#to_unit} with `:$1`.
728
+ #
729
+ # @return [Integer] the quantity of the unit in the duration.
730
+ #
731
+ # @see #to_unit
732
+ def self.define_to_unit(unit)
733
+ define_method("to_#{unit}") do
734
+ self.to_unit(unit)
735
+ end
736
+ end
737
+
738
+ public
739
+
740
+ # NOTE: We need to manually spell out each unit with `define_to_unit` to
741
+ # get proper documentation for each method. To ensure that we don't miss
742
+ # any units, there's a test in `duration_spec.rb` to ensure each of these
743
+ # methods actually exist.
744
+
745
+ self.define_to_unit(:seconds)
746
+ self.define_to_unit(:minutes)
747
+ self.define_to_unit(:hours)
748
+ self.define_to_unit(:days)
749
+ self.define_to_unit(:weeks)
750
+ self.define_to_unit(:months)
751
+ self.define_to_unit(:years)
752
+ self.define_to_unit(:decades)
753
+ self.define_to_unit(:centuries)
754
+ self.define_to_unit(:millennia)
755
+ self.define_to_unit(:second)
756
+ self.define_to_unit(:minute)
757
+ self.define_to_unit(:hour)
758
+ self.define_to_unit(:day)
759
+ self.define_to_unit(:week)
760
+ self.define_to_unit(:month)
761
+ self.define_to_unit(:year)
762
+ self.define_to_unit(:decade)
763
+ self.define_to_unit(:century)
764
+ self.define_to_unit(:millennium)
765
+ end
766
+ end