Get date N months/years in the past

I have been reading the docs for Elixir’s Date/DateTime libraries but I cannot find a way to calculate a date which is either N months or N years before/after a particular date.

Date.add/2 appears to only accept days.
DateTime.add/3 will only accept “up to” :seconds

Can someone point me in the right direction?

I generally use third party library for this, although maybe it’s possible withing standard library these days, but I default to Timex.shift from timex library:

https://hexdocs.pm/timex/Timex.html#shift/2

3 Likes

ex_cldr_calendars also provides the ability to add date parts to dates: Cldr.Calendar — Cldr Calendars v1.19.0

    iex> Cldr.Calendar.minus ~D[2019-03-01], :days, 1
    ~D[2019-02-28]
    
    iex> Cldr.Calendar.minus ~D[2019-03-01], :months, 1
    ~D[2019-02-01]
    
    iex> Cldr.Calendar.minus ~D[2019-03-01], :quarters, 1
    ~D[2018-12-01]
    
    iex> Cldr.Calendar.minus ~D[2019-03-01], :years, 1
    ~D[2018-03-01]
8 Likes

I would love to have this feature in Elixir. To me it is really the last pending feature in the Calendar module. It seems it is a simple contract on a function called “plus”, which I would call “shift” instead.

@kip, how are the rules generated in your case? Automatically from CLDR? Is there a way we could see the rules converted to Elixir code for the ISO calendar?

Thank you!

7 Likes

@josevalim, there’s no CLDR dependency on this part - its all straight Calendar callbacks.

The only part that is tricky is what to do with something like Calendar.shift ~[2016-02-29], :year, 1. It is an invalid result?

In my implementation I have an option :coerce to force a valid date at the end of the target month. I’ve never liked it. Suggestions very welcome on how to handle this.

Happy to draft a PR for this, its not very complicated other than that issue.

9 Likes

Timex has the concept of ambiguous datetimes, which force you to decide whether you want to take the earlier or later time.

1 Like

Same on DateTime.shift_zone.

The ambiguous DateTime is about daylight summer time, which is a separate issue from invalid dates. That’s because shift should be considered a shift on the wall clock instead of shifting the data in a contiguous line. This implies two things:

  1. You can land on invalid datetimes caused by datetime shifts in a timezone
  2. You can land on a date that does not really exist

For the second one, which was the one @kip mentioned, I suggest handling it with as a rounding operation, so we can either round :up or :down. I would pick :down as the default so you always land within the same month. Note this applies for quarter/month/year shifting.

For example, adding one day to Feb 28th is always March 1st. No invalid date is built in the process. Adding one month to Jan 31st should be Feb 28th (unless you have a leap year).

This further introduces complication related to commutative property of those operators. For example:

~D[2022-01-31] + 1 month + 1 month == ~D[2022-03-28]
~D[2022-01-31] + 2 month == ~D[2022-03-31]

That’s why shifting operations are often done with durations, because you need to express the whole operation in one tackle. For example, consider the difference between:

~D[2020-02-29] + 1 year + 1 month == ~D[2021-03-28]
~D[2020-02-29] + 1 year and 1 month == ~D[2021-03-29]

However, things get a bit trickier once you consider 1 year and -1 day. Do you remove the day from the resolved date or from the invalid date? E.g. which one is the desired result?

~D[2020-02-29] + 1 year and -1 day == ~D[2021-02-27]
~D[2020-02-29] + 1 year and -1 day == ~D[2021-02-28]

I think it should be the first. So my proposal would be to start with:

NaiveDateTime.shift(naive_date_time, shift_options) :: {:ok, naive_date_time}

shift_options is a keyword list with year, quarter, month, week, day, and round. shift_options will be applied on the order of highest to lower (i.e. the order defined above). Once you apply any of year/quarter/month, you need to round to a valid date. Then add week/day as wall clock shifts too.

We could support hour/minute/second too, but because we are strictly talking about wall-clock shifts, they have to be implemented as wall-clock shifts instead of operations on iso days, which is slightly annoying.

Besides the contiguous time interpretation existing in add today, another difference between this and the add function is that we will consider the definition of duration/interval specific to your calendar while add always adds a unit of value specific to the gregorian calendar.

I would also suggest starting with the implementation for NaiveDateTime, and then move to DateTime and Date next. If you agree @kip, a pull request is welcome!

3 Likes

All those complications are why I’m excited about alternative approaches explored by calendar_interval [1] or being explored with tempo.

Adding “1 month” to a date can mean many things like for example:

  1. Add the duration of a month to the current date (usually defaulted to 30 days)
  2. Get to the same day of month in the next month (usually take the last day of month if the day itself is invalid)
  3. Get me the day the same number of days before the end of month next month
today = Date.utc_today()

# Case 1
in_30_days = Date.add(today, 30)

# Case 2
first_of_next_month = 
  today
  |> Date.end_of_month()
  |> Date.add(1)

add = max(today.day, Date.days_in_month(first_of_next_month))
same_day_of_month_in_next = Date.add(first_of_next_month, add - 1)

# Case 3
diff = Date.diff(today, Date.end_of_month(today))
same_day_before_end_of_month_next_month = 
  today
  |> Date.end_of_month()
  |> Date.add(1)
  |> Date.end_of_month()
  |> Date.add(diff)

I’d love an API, which can express the difference in meaning without feeling overly imperative. The last two of my examples in my mind are much more a variant of:

date = ~D[2022-06-14]
day_of_month = day_of_month(date)
month = to_month(date)
next_month = add(month, 1)
result = somehow_add_back_day_of_month_part(next_month, day_of_month)

This however becomes more complicated once not just a single “unit of time” is added, but multiple ones and the precisions need to be taken at multiple levels of the addition.

1: Eric Evans - Exploring Time - YouTube

2 Likes

Unfortunately I don’t think those libraries solve the problems I described above (@wojtekmach and @kip, pls correct me if I am wrong).

They do help expressing more complex constructs and how to compose and interpret them. However, all of the challenges in my previous comment already picked a unique answer for the question of what adding “1 month” means, which is:

After that, we still need to answer the following questions:

  1. what are the order of operations in a single interval? The order you add the units and round them matter. Maybe those libraries propose a semantic order already, which we could copy.

  2. What is your interpretation of time when it comes to days/seconds and timezones? Time may mean two things: wall-clock and a contiguous line. Working on the contiguous line is much easier but surprising, because it means adding 1 day during daylight saving time is actually adding 23 hours or 25 hours. A wall-clock operation would literally shift the day and return ambiguity errors if it falls exactly during dst.

And the second question in paticular is not answered in the libraries above (afaik).

I don’t think so as well – hence the “exploring” verb – but they feel closer to allowing users to express the different interpretations instead of the specific implementation picking one.

1 Like

Did you ever get anywhere here @kip @josevalim ?
Any help wanted?