Custom Leap Year Logic in DateTime, used when creating new objects as well

I stumbled upon this question on SO, and I found it very intriguing.
https://stackoverflow.com/questions/45703404/custom-leap-year-logic-in-datetime

After spending some time to find a solution I had to give up, maybe someone
can shed some light on this.

The problem is this:

I'm working on a fantasy text-based adventure, using the Curse library, for

fun and decided that I wanted to customize Dates, from the standard
Gregorian calendar. I've designed a new Date system that will stay
relatively similar:

   - 12 months with different names
   - The days in each month match up with the Gregorian calendar
   - 7 days (with different names) a week
   - 24 hours per day
   - 60 minutes per hour
   - 60 seconds per minute.
   - leap years every 15 years

The different names I'm able to handle through I18n. The big difference
ended up being that instead of a leap year being every 4 years, except
centuries, I wanted leap years to occur every 15 years. So, seeing that
Date <https://ruby-doc.org/stdlib-2.4.1/libdoc/date/rdoc/Date.html> has
the *_leap? methods, I tried creating a subclass overriding those in
hopes DateTime would check these methods when instantiating/adding to
dates:

require 'date'
class CustomDate < DateTime
  class << self
    def leap?(year)
      year % 15 == 0
    end

    def gregorian_leap?(year)
      leap?(year)
    end

    def julian_leap?(year)
      leap?(year)
    end
  end

  def leap?
    self.class.leap?(year)
  endend
CustomDate.leap?(2010) # => trueDate.leap?(2010) # => falseCustomDate.leap?(2000) # => falseDate.leap?(2000) # => true

So far looking good, but then when actually trying to use these CustomDate
nothing changed:

CustomDate.new(2000, 2, 29) # => hoped this would error because CustomDate.leap?(2000) is false...it didn'tCustomDate.new(2010, 2, 29) # => errors, but was hoping this would work

Adding days:

# Was hoping this would be 2010-02-29CustomDate.new(2010, 2, 28) + 1# => #<CustomDate: 2010-03-01T00:00:00+00:00 ...>
# Was hoping this one would be 2000-03-01CustomDate.new(2000, 2, 28) + 1# => #<CustomDate: 2000-02-29T00:00:00+00:00 ...>

So, unless I'm missing something with this approach, this isn't going to
work. Looking further, since Date is written in C, that kind of makes
sense.

Looking at the source code for Date, there's a function c_gregorian_leap_p
<https://github.com/ruby/ruby/blob/trunk/ext/date/date_core.c#L684> that
seems to be the work horse for all these methods I overrode on the Ruby
side. I'm thinking I'm going to need to write a c extension of some kind to
override *that* function instead of the ruby one. I've never written C
before, so before I get started down this path, I want to make sure I
haven't missed anything in actual Ruby code.

*TL;DR:* Is there anyway to customize Date/DateTime in ruby to have
custom leap year logic without writing some sort of C extension? With leap
years being the only change to a Gregorian calendar, seems silly to rewrite
Date/DateTime from scratch to change that one thing.

My initial thought was to use monkey patching, but of course as he already
mentioned when it come to actual usage of the said class things don't
really work out.

What I tried:
- Monkey patch the Date.leap? but the new method does not use that.
- Monkey patch the DateTime new method, like

def self.new(*args)
  raise 'invalid date' if CustomDate.leap?(args[0])
end

But this only work for the new leap year not the old.

Does anyone have any idea of how to accomplish this only with Ruby, even
if it might not be the best practice, or the only solution is to work
directly with C.

Cheers, Bud.

Why not use composition, rather than inheritance? It seems as if it would be less work. Some of these base classes can’t safely be subclassed anyway.

class FantasyDate
  WEEKDAYS = %w|Moonday Tewsday Wodensday Thorsday FirsDay Saturnday Sunday|

  def initialize(date); @date = date; end

  def weekday; WEEKDAYS[@date.strftime “%u”]; end

end

dt = FantasyDate(Date.today)
puts dt.weekday

…etc

Click here to view Company Information and Confidentiality Notice.<http://www.jameshall.co.uk/index.php/small-print/email-disclaimer>

The main issue is with the *leap?* method.

CustomDate.leap?(2010) # => true

Date.leap?(2010) # => false
CustomDate.leap?(2000) # => false
Date.leap?(2000) # => true

That's the expected behavior for leap year, but that should apply to object
creation as well:

CustomDate.new(2000, 2, 29) # => hoped this would error because CustomDate.leap?(2000) is false...it didn'tCustomDate.new(2010, 2, 29) # => errors, but was hoping this would work

I don't think composition would be able to solve this, since you can't

create a invalid Date, it will throw ArgumentError.

If you have your own date class, you can make your own leap method?

def leap?
  @date.year % 12 == 0
end

···

From: ruby-talk [mailto:ruby-talk-bounces@ruby-lang.org] On Behalf Of Mugurel Chirica
Sent: 17 August 2017 16:31
To: Ruby users
Subject: Re: Custom Leap Year Logic in DateTime, used when creating new objects as well

The main issue is with the leap? method.

CustomDate.leap?(2010) # => true
Date.leap?(2010) # => false
CustomDate.leap?(2000) # => false
Date.leap?(2000) # => true

That's the expected behavior for leap year, but that should apply to object creation as well:

CustomDate.new(2000, 2, 29) # => hoped this would error because CustomDate.leap?(2000) is false...it didn't

CustomDate.new(2010, 2, 29) # => errors, but was hoping this would work
I don't think composition would be able to solve this, since you can't create a invalid Date, it will throw ArgumentError.

Click here to view Company Information and Confidentiality Notice.<http://www.jameshall.co.uk/index.php/small-print/email-disclaimer>

CustomDate.new(2000, 2, 29)

This creates a new valid date, but it should throw ArgumentError, since in
the mentioned universe this date will be a leap year, and you don't have a
29th February in a leap year.

CustomDate.new(2010, 2, 29)

This throw ArgumentError since 2010 is a leap year, but in the said

universe the date is valid so the expected behavior is that a new DateTime
object will be created.

This extends to other dates operation, like addition etc.

Hope that clears things a bit.

Thank you.

CustomDate.new(2000, 2, 29)

This creates a new valid date, but it should throw ArgumentError, since in the mentioned universe this date will be a leap year, and you don't have a 29th February in a leap year.

If this is your class, and it doesn’t raise an ArgumentError when you want it to, then … make it raise an ArgumentError?

···

##########
    def initialize(year, month, day)
      fail ArgumentError if not_a_valid_fantasy_date(year, month, day)
      @basedate = Date.new(year, month, day)
    end
    #######

This extends to other dates operation, like addition etc.

Again: if CustomDate is your class, and it only contains a real date internally, then you can implement these however you like.

You might want to check out the Forwardable module in Ruby's standard library, which might be helpful in passing on those methods you want for CustomDate which are unchanged from those in Ruby's Date. (You only need this if you are using composition.)

Click here to view Company Information and Confidentiality Notice.<http://www.jameshall.co.uk/index.php/small-print/email-disclaimer>

I understand, unfortunately that only addresses only the easy part of the
problem which I already solved.

The difficult part of the problem is to create a date object that is valid
in the new universe. Let me explain it a bit better to see the requirements.

Imagine that in the new universe the month can have 40 days, implementing
the solution above will fail for CustomDate(2010, 1, 40) because that is
not a valid Date in our universe, but the expected behavior is to have it
create a new date since in our universe is a correct date.

[8] pry(main)> Date.new(2010, 2, 40) ArgumentError: invalid date from
(pry):8:in `new' # That's the Date behaviour and the current behaviour for
CustomDate
[11] pry(main)> #<CustomDate: 2000-02-40T00:00:00+00:00
((2451594j,0s,0n),+0s,2299161j)> # This is the expected behaviour

Imagine that in the new universe the month can have 40 days, implementing the solution above will fail for CustomDate(2010, 1, 40) because that is not a valid Date in our universe, but the expected behavior is to have it create a new date since in our universe is a correct date.

[8] pry(main)> Date.new(2010, 2, 40) ArgumentError: invalid date from (pry):8:in `new' # That's the Date behaviour and the current behaviour for CustomDate
[11] pry(main)> #<CustomDate: 2000-02-40T00:00:00+00:00 ((2451594j,0s,0n),+0s,2299161j)> # This is the expected behaviour
<<<<<<<<

Then it sounds as if you should not be trying to link your custom date object to a regular earth date at all. Ignore the Date class entirely.

For date maths, the easiest approach is probably to store the date internally as the number of days since an arbitrary “epoch start date”. Then subtracting a date from another date is trivial.

If you really need an equivalent earth date, then you can use the “days since epoch start” number to get that, too.

Don’t forget to implement a “<=>” method. This will mean that your dates can be sorted AND if you include the comparable mixin, you get all the date comparing functionality -- `if mydate1 > mydate2 …` -- for free.

class FantasyDate
  include Comparable
  attr_reader :days_since_epoch, :year, :month, :day

  def initialize(y, m, d)
    @year, @month, @day = y, m, d
    @days_since_epoch = calc_days(y,m,d)
  end

  def <=>(other); @days_since_epoch <=> other.days_since_epoch; end

  def leap?
    @year % 20 == 0 # or whatever
        end

  …
end

Click here to view Company Information and Confidentiality Notice.<http://www.jameshall.co.uk/index.php/small-print/email-disclaimer>