botanicus/hour-ruby

View on GitHub
lib/hour.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

class Hour
  # Abstract time unit class.
  #
  # Subclasses are decorating the `Hour` class with functionality
  # specific for their particular type (hours, minutes and seconds).
  #
  # @api private
  class Unit
    def value
      raise NotImplementedError.new("Override in subclasses.")
    end

    def initialize(hour)
      @hour = hour
    end
  end

  class HourUnit < Unit
    def value
      @hour.h
    end

    def round
      self.value + ((0..29).include?(@hour.m) ? 0 : 1)
    end
  end

  class MinuteUnit < Unit
    def value
      @hour.m
    end

    def round
      self.value + ((0..29).include?(@hour.s) ? 0 : 1)
    end

    def total
      @hour.h * 60 + self.value
    end

    def round_total
      self.total + ((0..29).include?(@hour.s) ? 0 : 1)
    end
  end

  class SecondUnit < Unit
    def value
      @hour.s
    end

    def total
      @hour.h * 60 * 60 + @hour.m * 60 + self.value
    end
  end

  # TODO: Test me and document me.
  def self.now(**opts)
    self.from_time(Time.now, **opts)
  end

  # TODO: document and write tests.
  def self.from_time(time, s: true)
    self.new(time.hour, time.min, s ? time.sec : false)
  end

  # Build an hour instance from an hour string.
  #
  #     Hour.parse("1:00:00")
  #     Hour.parse("1:00", "%h:%m?") # Will work with "1:00" or just "1".
  #
  # TODO: Implement me, test me and document me.
  def self.parse(serialised_hour, s: true)
    args = serialised_hour.split(':').map(&:to_i)

    if args.length == 3 && s
      self.new(*args)
    elsif args.length == 2 && !s
      self.new(*(args << false))
    elsif ((0..2).include?(args.length) && s) || ((0..1).include?(args.length) && !s)
      raise ArgumentError, "Too few segments (#{args.inspect})."
    elsif ((4..Float::INFINITY).include?(args.length) && s) || ((3..Float::INFINITY).include?(args.length) && !s)
      raise ArgumentError, "Too many segments (#{args.inspect})."
    end
  end

  # Build an hour instance from *either* **minutes** *or* **seconds**.
  # Unlike `.new`, either of these values can be over 60.
  #
  #     Hour.from(minutes: 85)  # => Hour.new(h: 1, m: 25)
  #     Hour.from(seconds: 120) # => Hour.new(m: 2)
  def self.from(minutes: 0, seconds: 0)
    if minutes != 0 && seconds != 0
      raise ArgumentError.new("Use either minutes OR seconds, not both.")
    end

    if minutes > 0 || (minutes == 0 && seconds == 0)
      self.new(h: minutes / 60, m: minutes % 60)
    else
      self.from(minutes: seconds / 60) + self.new(s: seconds % 60)
    end
  end

  attr_reader :h, :m, :s

  # Build an hour instance from *h*, *m* and *s*.
  # Raises an argument error if *m* or *s* is a value over 60.
  #
  # For instantiating this class from a *minutes* or *seconds* value over 60, use `.from`.
  def initialize(*args)
    if args.length == 1 && args.first.is_a?(Hash)
      initialize_from_keyword_args(**args.first)
    else
      # Pad with 0s.
      args = args + Array.new(3 - args.length, 0)
      @h, @m, @s = args
    end

    if @m > 60
      raise ArgumentError.new("Minutes must be a number between 0 and 60.")
    end

    if @s.respond_to?(:round) && @s > 60
      raise ArgumentError.new("Seconds must be a number between 0 and 60.")
    end
  end

  # Returns a new Hour instance returning the total time of the two hour instances.
  #
  #     Hour.new(m: 25, s: 10) + Hour.new(h: 1) # => Hour.new(1, 25, 10)
  def +(other)
    hours = @h + other.h + (@m + other.m + ((@s + other.s) / 60)) / 60
    minutes = (@m + other.m + ((@s + other.s) / 60)) % 60

    if @s && other.s
      seconds = (@s + other.s) % 60
    elsif (!@s) && (!other.s)
      seconds = false
    else
      raise "TODO: how to resolve this?"
    end

    self.class.new(hours, minutes, seconds)
  end

  def -(other)
    if other.to_decimal > self.to_decimal
      raise ArgumentError, "Negative hours not supported"
    end

    hours = @h - other.h - (@m - other.m - ((@s - other.s) / 60)) / 60
    minutes = (@m - other.m - ((@s - other.s) / 60)) % 60

    if @s && other.s
      seconds = (@s - other.s) % 60
    elsif (!@s) && (!other.s)
      seconds = false
    else
      raise "TODO: how to resolve this?"
    end

    self.class.new(hours, minutes, seconds)
  end

  def *(integer)
    raise ArgumentError, "must be an integer" unless integer.integer?
    self.class.from(seconds: (@h * integer * 3600) + (@m * integer * 60) + (@s * integer))
  end

  # Returns a decorator providing convenience methods for working with hours.
  #
  #     Hour.new(1, 25).hours.round # => 1
  #     Hour.new(1, 45).hours.round # => 2
  def hours
    HourUnit.new(self)
  end

  # Returns a decorator providing convenience methods for working with minutes.
  #
  #     Hour.new(1, 25, 52).minutes.value       # => 25
  #     Hour.new(1, 25, 52).minutes.round       # => 26
  #     Hour.new(1, 25, 52).minutes.total       # => 85
  #     Hour.new(1, 25, 52).minutes.round_total # => 86
  def minutes
    MinuteUnit.new(self)
  end

  # Returns a decorator providing convenience methods for working with seconds.
  #
  #     Hour.new(m: 1, s: 10).seconds.value # => 10
  #     Hour.new(1, 45, 10  ).seconds.total # => (1 * 60 * 60) + (45 * 60) + 10
  def seconds
    SecondUnit.new(self) if @s
  end

  # Returns string representation of the hour instance.
  #
  #     Hour.new(m: 1, s: 10 ).to_s # => "1:10"
  #     Hour.new(1, 45, false).to_s # => "1:45"
  #
  # TODO: Allow formatting string (to format hours to 2 digits etc).
  def to_s(format = nil)
    [(@h unless @h.zero?), format('%02d', @m), (format('%02d', @s) if @s)].compact.join(':')
  end

  alias_method :inspect, :to_s

  # Provisional.
  def to_decimal
    decimal = (@m / 60.0) + (@s / 3600.0)
    "#{@h}.#{decimal}"
    @h + decimal
  end

  def to_time(today = Time.now)
    Time.new(today.year, today.month, today.day, self.hours, self.minutes_over_the_hour)
  end

  [:==, :eql?, :<, :<=, :>, :>=, :<=>].each do |method_name|
    define_method(method_name) do |anotherHour|
      if anotherHour.is_a?(self.class)
        self.seconds.total.send(method_name, anotherHour.seconds.total)
      # elsif anotherHour.is_a?(Time)
      #   self.send(method_name, Hour.now)
      else
        raise TypeError.new("#{self.class}##{method_name} expects #{self.class} or Time object.")
      end
    end
  end

  private
  def initialize_from_keyword_args(h: 0, m: 0, s: 0)
    @h, @m, @s = h, m, s
  end
end

# https://github.com/botanicus/commonjs_modules
export { Hour } if defined?(export)