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.