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月08日T00: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.

asked Jun 12, 2016 at 15:48
\$\endgroup\$

1 Answer 1

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.

answered Jun 13, 2016 at 6:46
\$\endgroup\$
3
  • \$\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\$ Commented Jun 13, 2016 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\$ Commented Jun 13, 2016 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\$ Commented Jun 13, 2016 at 14:32

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.