2
\$\begingroup\$

I am relatively new to Ruby, having worked with Rails for about 8 months.

For fun and my own education, I have written a Ruby gem (no Rails) that models dates with a precision (second, minute, hour, day ... billion years). A big part of my goal was create dates from the date format on Wikidata, and format them as human-readable strings.

For example:

CarbonDate::Date.new(1914, 07, 28, precision: :decade).to_s
=> "1910s"

CarbonDate::Date.iso8601('+0632-06-08T00:00:00Z', 11).to_s
=> "8th June, 632"

The Date class:

module CarbonDate

  class Date

    @formatter = CarbonDate::StandardFormatter.new

    class << self
      attr_accessor :formatter
    end

    PRECISION = [
      {symbol: :second, level: 14},
      {symbol: :minute, level: 13},
      {symbol: :hour, level: 12},
      {symbol: :day, level: 11},
      {symbol: :month, level: 10},
      {symbol: :year, level: 9,},
      {symbol: :decade, level: 8,},
      {symbol: :century, level: 7,},
      {symbol: :millennium, level: 6,},
      {symbol: :ten_thousand_years, level: 5,},
      {symbol: :hundred_thousand_years, level: 4},
      {symbol: :million_years, level: 3},
      {symbol: :ten_million_years, level: 2},
      {symbol: :hundred_million_years, level: 1},
      {symbol: :billion_years, level: 0}
    ]

    attr_reader :precision, :year, :month, :day, :hour, :minute, :second

    def initialize(year = 1970, month = 1, day = 1, hour = 0, minute = 0, second = 0, precision: :second)
      month = 1 if month == 0
      day = 1 if day == 0
      self.precision = precision
      self.set_date(year, month, day)
      self.hour = hour
      self.minute = minute
      self.second = second
    end

    def precision=(value)
      p = PRECISION.find { |x| x[:symbol] == value }
      raise ArgumentError.new "Invalid precision #{value}" unless p
      @precision = p
    end

    def set_date(year, month, day)

      raise ArgumentError.new("Invalid date #{year}-#{month}-#{day}") unless (1..12).include? month
      raise ArgumentError.new("Invalid date #{year}-#{month}-#{day}") if (year.nil? || year == 0)

      begin
        ::Date.new(year, month, day)
      rescue ArgumentError
        raise ArgumentError.new("Invalid date #{year}-#{month}-#{day}")
      end

      @year = year.to_i
      @month = month
      @day = day

    end

    def year=(value)
      set_date(value, @month, @day)
    end

    def month=(value)
      set_date(@year, value, @day)
    end

    def day=(value)
      set_date(@year, @month, value)
    end

    def hour=(value)
      raise ArgumentError.new "Invalid hour #{value}" unless (0..23).include? value
      @hour = value
    end

    def minute=(value)
      raise ArgumentError.new "Invalid minute #{value}" unless (0..59).include? value
      @minute = value
    end

    def second=(value)
      raise ArgumentError.new "Invalid second #{value}" unless (0..59).include? value
      @second = value
    end

    def self.iso8601(string, precision_level)
      p = PRECISION.find { |p| p[:level] == precision_level}
      raise ArgumentError.new("Invalid precision level #{precision_level}") unless p
      if string[0] == '-'
        string = string[1..(string.length - 1)]
        bce = true
      else
        bce = false
      end
      d = string.split('T').map { |x| x.split /[-:]/ }.flatten.map(&:to_i)
      year = bce ? -d[0] : d[0]
      CarbonDate::Date.new(year, d[1], d[2], d[3], d[4], d[5], precision: p[:symbol])
    end

    def to_s
      CarbonDate::Date.formatter.date_to_string(self)
    end

    def to_date
      ::Date.new(@year, @month, @day)
    end

    def to_datetime
      ::DateTime.new(@year, @month, @day, @hour, @minute, @second)
    end

    def ==(another_date)
      return false if self.precision != another_date.precision
      self.to_datetime == another_date.to_datetime
    end

    def <=(another_date)
      self.to_datetime <= another_date.to_datetime
    end

    def >=(another_date)
      self.to_datetime >= another_date.to_datetime
    end

  end
end

The Formatter class:

module CarbonDate
  class Formatter
    def date_to_string(date)
      precision = date.precision.fetch(:symbol, nil)
      case precision
      when :billion_years then billion_years(date)
      when :hundred_million_years then hundred_million_years(date)
      when :ten_million_years then ten_million_years(date)
      when :million_years then million_years(date)
      when :hundred_thousand_years then hundred_thousand_years(date)
      when :ten_thousand_years then ten_thousand_years(date)
      when :millennium then millennium(date)
      when :century then century(date)
      when :decade then decade(date)
      when :year then year(date)
      when :month then month(date)
      when :day then day(date)
      when :hour then hour(date)
      when :minute then minute(date)
      when :second then second(date)
      else raise StandardError.new("Unrecognized precision: #{precision}")
      end
    end
  end
end

The StandardFormatter class:

module CarbonDate
  class StandardFormatter < Formatter

    BCE_SUFFIX = 'BCE'

    MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']

    private

    def year(date)
      y = date.year.abs.to_s
      return [y, BCE_SUFFIX].join(' ') if (date.year <= -1)
      y
    end

    def month(date)
      [MONTHS[date.month - 1], year(date)].join(', ')
    end

    def day(date)
      [date.day.ordinalize.to_s, month(date)].join(' ')
    end

    def hour(date)
      h = date.minute >= 30 ? date.hour + 1 : date.hour
      time = [pad(h.to_s), '00'].join(':')
      [time, day(date)].join(' ')
    end

    def minute(date)
      time = [pad(date.hour.to_s), pad(date.minute.to_s)].join(':')
      [time, day(date)].join(' ')
    end

    def second(date)
      time = [pad(date.hour.to_s), pad(date.minute.to_s), pad(date.second.to_s)].join(':')
      [time, day(date)].join(' ')
    end

    def decade(date)
      d = ((date.year.abs.to_i / 10) * 10).to_s + 's'
      return [d, BCE_SUFFIX].join(' ') if (date.year <= -1)
      d
    end

    def century(date)
      c = ((date.year.abs.to_i / 100) + 1).ordinalize + ' century'
      return [c, BCE_SUFFIX].join(' ') if (date.year <= -1)
      c
    end

    def millennium(date)
      m = ((date.year.abs.to_i / 1000) + 1).ordinalize + ' millennium'
      return [m, BCE_SUFFIX].join(' ') if (date.year <= -1)
      m
    end

    def ten_thousand_years(date)
      coarse_precision(date.year, 10e3.to_i)
    end

    def hundred_thousand_years(date)
      coarse_precision(date.year, 100e3.to_i)
    end

    def million_years(date)
      coarse_precision(date.year, 1e6.to_i)
    end

    def ten_million_years(date)
      coarse_precision(date.year, 10e6.to_i)
    end

    def hundred_million_years(date)
      coarse_precision(date.year, 100e6.to_i)
    end

    def billion_years(date)
      coarse_precision(date.year, 1e9.to_i)
    end

    def coarse_precision(date_year, interval)

      date_year = date_year.to_i
      interval = interval.to_i

      year_diff = date_year - ::Date.today.year
      return "Within the last #{number_with_delimiter(interval)} years" if (-(interval - 1)..0).include? year_diff
      return "Within the next #{number_with_delimiter(interval)} years" if (1..(interval - 1)).include? year_diff

      rounded = (year_diff.to_f / interval.to_f).round * interval
      return "in #{number_with_delimiter(rounded.abs)} years" if rounded > 0
      return "#{number_with_delimiter(rounded.abs)} years ago" if rounded < 0

      nil
    end

    def number_with_delimiter(n, delim = ',')
      n.to_i.to_s.reverse.chars.each_slice(3).map(&:join).join(delim).reverse
    end

    def pad(s)
      s.rjust(2, '0')
    end

  end
end

I have removed comments from the code here, for the sake of brevity. The full code base can be found here.

I would really appreciate feedback, as I am striving to be a better programmer.

\$\endgroup\$
1
\$\begingroup\$

First thing - Date::PRECISION. Can't find purpose of level key.

I've noticed that is possibility of DRY-ing Formatter class:

class Formatter
  def date_to_string(date)
    precision = date.precision.fetch(:symbol, :nil)
    public_send(precision, date)
  rescue NoMethodError
    raise StandardError.new("Unrecognized precision: #{precision}")
  end
end

Similar refactoring can be done for StandardFormatter class.

And for Date#set_date: raise ArgumentError.new("Invalid date #{year}-#{month}-#{day}") can be easily DRY-ed up.

That's it for now.

\$\endgroup\$
  • \$\begingroup\$ Thanks so much. You are quite correct about the :level key being unused, and with the repeated ArgumentError for invalid dates. I have 2 questions about your proposal for Formatter. First, did you purposefully symbolize :nil? Secondly, is it not a bit 'dirty' to call functions by a name, with public_send? It seems like it could backfire somehow by allowing client code to call an unwanted function. \$\endgroup\$ – Bradley Marques Jun 13 '16 at 10:34
  • \$\begingroup\$ 1. nil => :nil. public_send accepts symbol. Passing nil will break the function. 2. public_send. This method is widely used for meta-programming. If we call protected or private method (aka "unwanted") ruby will raise NoMethodError. \$\endgroup\$ – Sergii K Jun 13 '16 at 11:49
  • \$\begingroup\$ Thanks for the additional feedback. I also found this StackOverflow question and this book that support the fact that public_send (and send) is acceptable to use. Thanks once again. \$\endgroup\$ – Bradley Marques Jun 13 '16 at 14:32

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.