require 'active_support/duration' module ActiveSupport class Duration class << self # Creates a new Duration from a Hash of parts (inverse of Duration#parts). # # Surprising that upstream ActiveSupport doesn't provide this method # # normalize: true (the default) changes 30.5m into 30m, 30s, for example. def from_parts(parts, normalize: true) parts = parts.compact.reject { |k, v| v.zero? } duration = new(calculate_total_seconds(parts), parts) if normalize duration.normalize else duration end end alias parse_parts from_parts def units_largest_first # Reverse since PARTS_IN_SECONDS is ordered smallest to largest PARTS_IN_SECONDS.keys.reverse.freeze end def next_smaller_unit(unit) i = PARTS.index(unit) or raise(ArgumentError, "unknown unit #{unit}") PARTS[i + 1] end def smaller_units(unit) # The index of unit; we only want parts with indexes > this index unit_i = units_largest_first.index(unit) or raise(ArgumentError, "unknown unit #{unit}") units_largest_first.select.with_index { |key, i| i > unit_i } end end # Re-builds the Duration using build(value). Useful if you may have "extra" seconds, minutes, # etc. that could be carried over to the next higher unit, such as if you've built a Duration # using Duration.seconds and a number of seconds > 60. # # ActiveSupport::Duration.seconds(61).normalize # => 1 minute and 1 second # def normalize Duration.build(value) end # Replaces parts of duration with given part values. Unlike #change_cascade and Time#change, # *only* ever changes the given parts; it does *not* reset any smaller-unit parts. def change(**changes) self.class.from_parts( parts.merge(changes), normalize: false ) end # Changes the given part(s) of the duration and resets any smaller parts. # # @example # (9.hours + 10.minutes + 40.seconds).change_cascade(hours: 12) # => 12 hours # (9.hours + 10.minutes + 40.seconds).change_cascade(minutes: 5) # => 9 hours and 5 minutes # # Similar to Time#change # But note that the keys are plural, so :years instead of :year. # Should we allow key aliases? Should we raise ArgumentError if key not recognized? Yes. (Why doesn't Time#change?) # or should this be named truncate? or change_reset_smaller_parts? # #----------- # Returns a new Duration where one or more of the elements have been changed according # to the +options+ parameter. The time options (:hour, :min, # :sec, :usec, :nsec) reset cascadingly, so if only # the hour is passed, then minute, sec, usec and nsec is set to 0. If the hour # and minute is passed, then sec, usec and nsec is set to 0. The +options+ parameter # takes a hash with any of these keys: :year, :month, :day, # :hour, :min, :sec, :usec, :nsec, # :offset. Pass either :usec or :nsec, not both. # # Time.new(2012, 8, 29, 22, 35, 0).change(day: 1) # => Time.new(2012, 8, 1, 22, 35, 0) # Time.new(2012, 8, 29, 22, 35, 0).change(year: 1981, day: 1) # => Time.new(1981, 8, 1, 22, 35, 0) # Time.new(2012, 8, 29, 22, 35, 0).change(year: 1981, hour: 0) # => Time.new(1981, 8, 29, 0, 0, 0) def change_cascade(options) options.assert_valid_keys(*PARTS_IN_SECONDS, :nsec, :usec) reset = false new_parts = {} new_parts[:years] = options.fetch(:years, parts[:years]) ; reset ||= options.key?(:years) new_parts[:months] = options.fetch(:months, reset ? 0 : parts[:months]) ; reset ||= options.key?(:months) new_parts[:days] = options.fetch(:days, reset ? 0 : parts[:days]) ; reset ||= options.key?(:days) new_parts[:hours] = options.fetch(:hours, reset ? 0 : parts[:hours]) ; reset ||= options.key?(:hours) new_parts[:minutes] = options.fetch(:minutes, reset ? 0 : parts[:minutes]); reset ||= options.key?(:minutes) new_parts[:seconds] = options.fetch(:seconds, reset ? 0 : parts[:seconds]) if new_nsec = options[:nsec] raise ArgumentError, "Can't change both :nsec and :usec at the same time: #{options.inspect}" if options[:usec] new_usec = Rational(new_nsec, 1000) else new_usec = nil # new_usec = options.fetch(:usec, (options[:hour] || options[:min] || options[:sec]) ? 0 : # Rational(nsec, 1000)) end if new_usec raise ArgumentError, "argument out of range" if new_usec >= 1000000 new_parts[:seconds] += Rational(new_usec, 1000000) end self.class.from_parts( new_parts.compact.reject { |k, v| v.zero? }, normalize: false, ) end # Returns duration rounded to the nearest value having a precision of `precision`, which is a # unit such as :hours, which would mean "round to the nearest hour". The smaller parts (:minutes # and :seconds in this example) are turned into a fraction of the requested precision (:hours), # which is then added to requested precision part. Finally, `round` is called on the requested # precision part (hours in this example). # # If optional [ndigits] [, half: mode] arguments are supplied, they are passed along to # [round](https://ruby-doc.org/core/Float.html#method-i-round). # # @example # 30.seconds.round(:minutes) #=> 1 minute # 89.seconds.round(:minutes) #=> 1 minute # 90.seconds.round(:minutes) #=> 2 minutes # (1.hour + 30.seconds).round(:minutes) #=> 1 hour and 1 minute # # 2.5.seconds.round #=> 3 seconds # 2.5.seconds.round(half: :down) #=> 2 seconds # # @raises ArgumentError # TODO raise ArgumentError if precision not recognized as a unit # def round(precision = smallest_unit, *args, **opts) #puts "Rounding #{parts.inspect} (in particular #{parts[precision]} #{precision}) to nearest #{precision.inspect}" new_part_value = orig_part_value = (parts[precision] || 0) fraction = smaller_parts_to_fraction_of(precision) # Usually fraction is in the range 0..1, unless the smaller units are overflowed (non-normalized) new_part_value += fraction #puts "Adding #{orig_part_value} + fraction parts #{fraction.inspect} (#{fraction.to_f}) = #{new_part_value} (#{new_part_value.to_f})" new_part_value = new_part_value.round(*args, **opts) change_cascade( precision => new_part_value ) end # Convert the parts that are smaller than `unit` to be a fraction (Rational) of that # `unit`. # # For example, if `unit` is :hours and self is 1h 29m 60s, then it would look at the parts # smaller than hour, 29m 60s, which is the same as 30m, and would convert that to a fraction of # hours, which would be 30m/60m = 1/2r. # def smaller_parts_to_fraction_of(unit) #next_smaller_unit = self.class.next_smaller_unit(unit) #next_smaller_unit_in_s = ActiveSupport::Duration::PARTS_IN_SECONDS[next_smaller_unit] # 1 if unit == :minutes #puts %(unit_in_s=#{(unit_in_s).inspect}, next_smaller_unit_in_s=#{(next_smaller_unit_in_s).inspect}) smaller_parts = smaller_parts(unit) numerator_s = ActiveSupport::Duration.send(:calculate_total_seconds, smaller_parts) denominator_s = ActiveSupport::Duration::PARTS_IN_SECONDS[unit] # 60 if unit == :minutes fraction = Rational(numerator_s, denominator_s) #puts "#{smaller_parts.inspect} converted to fraction #{numerator_s}/#{denominator_s} = #{fraction} (#{fraction.to_f})" fraction end # Returns all parts than `unit` as a Hash that is a subset of self.parts. # # For example, if `unit` is :hours and self is 1h 29m 60s, then it would return the parts # smaller than hour, 29m 60s, as the hash { minutes: 29, seconds: 60 }. # def smaller_parts(unit) parts.slice *ActiveSupport::Duration.smaller_units(unit) end def smallest_part [parts.to_a.last].to_h end def smallest_unit parts.to_a.last[0] end # Truncates the Duration to the specified precision. All smaller parts are discarded. # # Similar to https://ruby-doc.org/core-2.7.1/Float.html#method-i-truncate # def truncate(precision = smallest_unit, *args, **opts) #puts %(Truncating #{parts.inspect} to #{precision.inspect}) # TODO: only use truncate here if :seconds or if part is Float ? # or just always pass them along, although they are probably only needed for :seconds part_value = (parts[precision] || 0) new_part_value = part_value.truncate(*args) change_cascade( precision => new_part_value ) end # TODO: For completeness to complement truncate: # https://ruby-doc.org/3.2.2/Float.html#method-i-ceil # https://ruby-doc.org/3.2.2/Rational.html#method-i-round # def ceil # end # def floor # end end end