January 1st 0001 is actually January 3rd 0001 or is it?

braindeaf

RobL

Posted on November 18, 2023

January 1st 0001 is actually January 3rd 0001 or is it?

I came across a rather strange bug in some client code yesterday which had me stumped for quite some time. They're using a date_select to present the day and month and storing that day with just any arbitrary year. It doesn't really matter just that you can retrieve the date.

f.date_select :starts_on, { order: %i[day month], include_blank: true }
Enter fullscreen mode Exit fullscreen mode

As we might expect the date select creates MultiParameterAttributes which will result in our parameters containing values like so.

{
  "starts_on(1i)": "1", # year
  "starts_on(2i)": "1", # month
  "starts_on(3i)": "1". # day
}
Enter fullscreen mode Exit fullscreen mode

The resulting html from the date select produces 2 selects for the month and day, but since we're excluding the year we get a hidden field for the year since we need starts_on(1i), starts_on(2i) and starts_on(3i) parameters to construct a Date/Time.

<input type="hidden" id="_starts_on_1i" name="[starts_on(1i)]" value="1" autocomplete="off" />
Enter fullscreen mode Exit fullscreen mode

It's worth noting that the year defaults "1" because we are using include_blank: true because we want the the date to be set or not. This has interesting side-effects. In part because we're using Mongoid, Mongoid stores dates as times so a Time object is being created when we mass-assign it. We're effectively doing this.

Time.new(1,1,1)
=> 0001-01-01 00:00:00 +0100
Enter fullscreen mode Exit fullscreen mode

Which seems fine, until you call .to_date on it

 Time.new(1,1,1).to_date
=> Mon, 03 Jan 0001
Enter fullscreen mode Exit fullscreen mode

Oh. Erm... I was all ready to start reporting this as a bug. It's not really a bug but an anomaly. The first thing I can find out about it is on this Quora thread

Ok, calendars at that time varied and who am I to argue, it was 2000 years ago. I am by no means a calendar expert.

Image description

What I do know is that I need a solution to my problem, suddenly moving from Rails 6.0 to Rails 6.1 (in Ruby 3.2.2) this has appeared. The fastest solution without getting bogged down and changing 100 different selects is to start thinking in terms of another default year.

This would be easy if we didn't have to use include_blank

helper.date_select '', :starts_on, { order: %i[day month], default: Date.new(1970,1,1) }
# or 
helper.date_select '', :starts_on, { order: %i[day month], default: { year: 1970 } }
Enter fullscreen mode Exit fullscreen mode

outputs

<input type="hidden" id="_starts_on_1i" name="[starts_on(1i)]" value="1970" autocomplete="off" />
Enter fullscreen mode Exit fullscreen mode

and 1970 onwards uses a calendar we can rely on,

Time.new(1970, 1, 1).to_date
=> 1970-01-01 00:00:00 +0100
Time.new(1970,1,1).to_date
=> Thu, 01 Jan 1970
Enter fullscreen mode Exit fullscreen mode

However since we're using include_blank I need to just hack date_select to use "1970" instead of "1" and be done with it.

module ActionView
  module Helpers
    class DateTimeSelector
      def select_year
        if !year || @datetime == 0
          val = "1" # hardcoded to "1"
          middle_year = Date.today.year
        else
          val = middle_year = year
        end

        if @options[:use_hidden] || @options[:discard_year]
          build_hidden(:year, val.to_i < 1800 ? "1970" : val) # don't use "1" please.
        else
          options                     = {}
          options[:start]             = @options[:start_year] || middle_year - 5
          options[:end]               = @options[:end_year] || middle_year + 5
          options[:step]              = options[:start] < options[:end] ? 1 : -1
          options[:leading_zeros]     = false
          options[:max_years_allowed] = @options[:max_years_allowed] || 1000

          if (options[:end] - options[:start]).abs > options[:max_years_allowed]
            raise ArgumentError, "There are too many years options to be built. Are you sure you haven't mistyped something? You can provide the :max_years_allowed parameter."
          end

          build_select(:year, build_year_options(val, options))
        end
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

It's heavy handed but there was no other way to change that hardcoded "1" default.

build_hidden(:year, val.to_i < 1800 ? "1970" : val)
Enter fullscreen mode Exit fullscreen mode

Additionally, I change the value to 1970 if the date happend to be less than 1800 (either by default or if it from the database), so if the value in the database is already 0001 or 0000, then the date_select will use the values for year that are already set which means we could potentially start poisoning the data in the database unless we go and correct all values to use 1970 in advance of rolling this out.

💖 💪 🙅 🚩
braindeaf
RobL

Posted on November 18, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related