Tox - Some structs and functions to work with dates, times, periods, and intervals

Hello,
I have released Tox 0.1.0, a little library for working with DateTime, NaiveDateTime, Date, and Time. It also adds Tox-Period and Tox-Interval. My motivation was to have some date-time calculation functions that work with any time zone database and different calendars. So you can use Tox with TimeZoneInfo, Tz, and Tzdata.
For the tests and examples, I use the Calendar.ISO and the calendars Cldr.Calendar.Coptic, Cldr.Calendar.Ethiopic, and Cldr.Calendar.Persian implemented by @kip.

Some examples:

add:

iex> datetime = DateTime.from_naive!(~N[2020-07-19 12:00:00], "Europe/Berlin")
#DateTime<2020-07-19 12:00:00+02:00 CEST Europe/Berlin>
iex> Tox.DateTime.add(datetime, year: 1, month: 1, day: 1, hour: 1, minute: 1)
#DateTime<2021-08-20 13:01:00+02:00 CEST Europe/Berlin>
iex> Tox.DateTime.add(datetime, year: -1, month: 1, day: 1, hour: 1, minute: 1)
#DateTime<2019-08-20 13:01:00+02:00 CEST Europe/Berlin>
iex> Tox.DateTime.add(datetime, week: 1)    
#DateTime<2020-07-26 12:00:00+02:00 CEST Europe/Berlin>

iex> datetime = DateTime.from_naive!(~N[2020-07-19 12:00:00], "Europe/Berlin") 
...> |> DateTime.convert!(Cldr.Calendar.Coptic)
#DateTime<1736-11-12 12:00:00+02:00 CEST Europe/Berlin Cldr.Calendar.Coptic>
iex> Tox.DateTime.add(datetime, month: 1)
#DateTime<1736-12-12 12:00:00+02:00 CEST Europe/Berlin Cldr.Calendar.Coptic>
iex> Tox.DateTime.add(datetime, month: 2)
#DateTime<1736-13-05 12:00:00+02:00 CEST Europe/Berlin Cldr.Calendar.Coptic>

iex> DateTime.from_naive(~N[2020-03-29 02:30:00], "Europe/Berlin")
{:gap, #DateTime<2020-03-29 01:59:59.999999+01:00 CET Europe/Berlin>,
 #DateTime<2020-03-29 03:00:00+02:00 CEST Europe/Berlin>}

iex> datetime = DateTime.from_naive!(~N[2020-03-29 01:30:00], "Europe/Berlin")
#DateTime<2020-03-29 01:30:00+01:00 CET Europe/Berlin>
iex> Tox.DateTime.add(datetime, hour: 1)
#DateTime<2020-03-29 03:30:00+02:00 CEST Europe/Berlin>

beginning_of_, end_of_:

iex> ~N[2020-07-12 11:12:13]
...> |> DateTime.from_naive!("America/Winnipeg")
...> |> Tox.DateTime.beginning_of_year()
#DateTime<2020-01-01 00:00:00-06:00 CST America/Winnipeg>

iex> ~N[2020-07-12 11:12:13]
...> |> DateTime.from_naive!("Europe/Berlin")
...> |> Tox.DateTime.end_of_year()
#DateTime<2020-12-31 23:59:59.999999+01:00 CET Europe/Berlin>

# In 1994 the year ended one day earlier in the time zone Pacific/Kirimati.
iex> ~N[1994-07-12 11:12:13]
...> |> DateTime.from_naive!("Pacific/Kiritimati")
...> |> Tox.DateTime.end_of_year()
#DateTime<1994-12-30 23:59:59.999999-10:00 -10 Pacific/Kiritimati>

iex> datetime = DateTime.from_naive!(~N[2020-07-15 12:00:00], "Europe/Berlin")
#DateTime<2020-07-15 12:00:00+02:00 CEST Europe/Berlin>
iex> Tox.DateTime.end_of_week(datetime)                                 
#DateTime<2020-07-19 23:59:59.999999+02:00 CEST Europe/Berlin>
iex> Tox.DateTime.end_of_day(datetime)                                  
#DateTime<2020-07-15 23:59:59.999999+02:00 CEST Europe/Berlin>
iex> Tox.DateTime.beginning_of_week(datetime)
#DateTime<2020-07-13 00:00:00+02:00 CEST Europe/Berlin>
iex> Tox.DateTime.beginning_of_day(datetime)
#DateTime<2020-07-15 00:00:00+02:00 CEST Europe/Berlin>

iex> datetime = DateTime.from_naive!(~N[2020-07-19 12:00:00], "Europe/Berlin")
#DateTime<2020-07-19 12:00:00+02:00 CEST Europe/Berlin>
iex> datetime = DateTime.convert!(datetime, Cldr.Calendar.Coptic)       
#DateTime<1736-11-12 12:00:00+02:00 CEST Europe/Berlin Cldr.Calendar.Coptic>
iex> Tox.DateTime.end_of_year(datetime)                                 
#DateTime<1736-13-05 23:59:59.999999+02:00 CEST Europe/Berlin Cldr.Calendar.Coptic>

Tox.Period, Tox.Interval:

iex> now = DateTime.from_naive!(~N[2020-07-12 21:33:43], "America/New_York")
iex> period = Tox.Period.new!(month: 1)
#Tox.Period<P1M>
iex> interval = Tox.Interval.new!(Tox.DateTime.beginning_of_month(now), period)
#Tox.Interval<[2020-07-01T00:00:00-04:00/P1M[>
iex> Tox.Interval.contains?(interval, now)
true
iex> Tox.Interval.since_start(interval, now, :millisecond)
{:ok, 1028023000}
iex> Tox.Interval.until_ending(interval, now, :millisecond)
{:ok, 1650377000}
iex> Tox.Interval.until_ending(interval, Tox.DateTime.add(now, month: 1), :millisecond)
:error
9 Likes

This looks great @Marcus!

We were recenetly having discussions about adding similar functionality directly to Elixir, so if you are interested in joining the discussion, we would love your $.02 on it: https://github.com/elixir-lang/elixir/pull/10199

12 Likes

This looks indeed very good. I have one piece of feedback / question:

Do the functions have to be scoped through the .DateTime suffix? Can’t most (or all) of them be put in the main Tox module? I mean, I could always just have alias Tox.DateTime, as: TDT in my user modules but just curious what made you go for such name-spacing.

2 Likes

@dimitarvp the example above show just Tox.DateTime, but Tox contains also Tox.NaiveDateTime, Tox.Date and Tox.Time all with here own add/2 function. The idea was not a Tox API but Tox as a namespace for some helpful modules and structs. It is the very first version an I am open to suggestions.

3 Likes

@Marcus This looks great. Date/time handling is one of the concepts in Elixir that I’m never quite sure whether to use a library or whether it is available in core and I’m just not understanding it. It does seem like some of these things are evolving to get pulled into core though. I’ve previously been using the Timex library to fill in the gaps of necessary functionality. Would this be a direct alternative to that or is this different in its goals? It’s fine either way as it is nice to have multiple options.

3 Likes

This is mostly due to the fact that elixir doesn’t implement functionality, which doesn’t soundly cover the complexity involved, which takes effort and therefore time. E.g. what does month: 1 do when being added to 31. of January? Will it add 31 days, 30 days or result in 27. of February as the last day of the next month?

Given the constant movement I can however understand that it’s not easy to keep up to date at all times.

4 Likes

Yep, I appreciate that these are really hard problems and there seems to be continual improvement in this area. Either way, I was able to get my calendaring functionality working with a mix of core, tzdata, calecto, and timex. Or at least my tests are green so I think it’s working. :slight_smile:

3 Likes

Timex is a rich, comprehensive Date/Time library for Elixir projects, with full timezone support via the :tzdata package.

Tox has far fewer features. Tox needs Elixir >= 1.8 and Timex supports Elixir >= 1.6. But Timex is tightly coupled to Tzdata (as I know). Tox works together with every Calendar.TimeZoneDatabase. To have a Calendar.TimeZoneDatabase independent implementation was my main motivation for Tox after I have released TimeZoneInfo.
So, it’s on you if you need all the features from Timex or if Tox is just enough. I have not planed to add more functionality to Tox, maybe there is too much in it right now and maybe something becomes obsolete with the next version of Elixir.

2 Likes

@LostKobrakai good point and exactly before I get ready this post:

The awkward sites of Tox.
The function adds sound associative, but it isn’t.

iex> ~D[2020-03-30] |> Tox.Date.add(month: 1)
~D[2020-04-30]
iex> ~D[2020-03-31] |> Tox.Date.add(month: 1)
~D[2020-04-30]
iex> ~D[2020-03-31] |> Tox.Date.add(month: 1) |> Tox.Date.add(month: 1)
~D[2020-05-30]
iex> ~D[2020-03-31] |> Tox.Date.add(month: 2)
~D[2020-05-31]

This example is from the talk “Exploring Time” by Eric Evans mentioned by @wojtekmach in his post CalendarInterval - Functions for working with calendar intervals .

To add to that, most of Timex functionality has been included into Elixir itself at this point. The pending parts are:

  1. multiple timezone support - built into Elixir but requires tzdata
  2. datetime formatting - it will be in the next Elixir release (v1.11)
  3. datetime parsing - requires timex
  4. datetime shifting - requires timex or tox

So I like Tox exactly because it solves one problem well. Now we only need someone to cover or extract parsing from timex and we will have all functionality either in Elixir or available in a meal-piece fashion. :slight_smile:

5 Likes

Stealing from Timex naming, I wonder if it should be called shift, exactly to steer away from the notation it may be associative.

2 Likes

I am partial to shift at least partially because I’m used to it with Timex, but also because it looks more natural to me when subtracting (shift(days: -3). add suggests to me it expects a positive value.

2 Likes

Well, I’d value ergonomics over almost anything else:

defmodule Tox do
  def add(%DateTime{} = t, how_much), do: # ...
  def add(%NaiveDateTime{} = t, how_much), do: # ...
end


etc. Pattern-matching function heads. But as said above, far from critical. I can just alias the module I need to something shorter at any time of my choosing.

1 Like

I like the alignment with elixir’s core api, as sometimes details / edge cases are different depending on the type of input. Not sure if that’s the case for Tox though. Also functions are “downward” compatible.

iex(2)> Date.add(~U[2020-06-01 15:00:00Z], 1)
~D[2020-06-02]
2 Likes

Didn’t know that, thank you.

I have renamed add to shift.

I like it too and the functions are also “downward” compatible.

iex> Tox.Date.shift(~U[2020-06-01 15:00:00Z], month: 1)
~D[2020-07-01]
iex> Tox.Time.shift(~U[2020-06-01 15:00:00Z], hour: 1)
~T[16:00:00.000000]

But the alignment with elixir’s core API doesn’t break with the new Tox.shift/2

iex> Tox.shift(~U[2020-06-01 15:00:00Z], month: 1)
~U[2020-07-01 15:00:00Z]
2 Likes

I’m still not convinced with the arguments of not having a subtract function :sweat_smile:

https://groups.google.com/forum/#!topic/elixir-lang-core/tYmBpKDMdow

Have to pass a negative value into add; but when we read “add”, we naturally read “value is increasing”.

In Tox the function is renamed to shift.

iex> Tox.Date.shift(~D[2020-07-25], month: 1, day: -1)
~D[2020-08-24]

In case of Date.add/2 like

iex> Date.add(~D[2000-01-03], -2)
~D[2000-01-01]

I think add/2 with a negated value is clear enough.
The renaming of the function in ‘Tox’ was motivated in order not to give the impression that it was associative.

I’ve just noticed that Tox.DateTime.shift silently swallows ambiguous and gap results. I would’ve expected it to have the same return type as DateTime.new of exposing those options to the user, not just DateTime.t().

I have rather stuck to DateTime.add/4:

iex> datetime = DateTime.from_naive!(~N[2020-03-29 01:59:59], "Europe/Berlin")
#DateTime<2020-03-29 01:59:59+01:00 CET Europe/Berlin>
iex> Tox.DateTime.shift(datetime, second: 1)
#DateTime<2020-03-29 03:00:00+02:00 CEST Europe/Berlin>
iex> DateTime.add(datetime, 1, :second)
#DateTime<2020-03-29 03:00:00+02:00 CEST Europe/Berlin>
iex> datetime = DateTime.from_naive!(~N[2020-03-29 03:00:00], "Europe/Berlin")
#DateTime<2020-03-29 03:00:00+02:00 CEST Europe/Berlin>
iex> Tox.DateTime.shift(datetime, second: -1)
#DateTime<2020-03-29 01:59:59+01:00 CET Europe/Berlin>
iex> DateTime.add(datetime, -1, :second)
#DateTime<2020-03-29 01:59:59+01:00 CET Europe/Berlin>

Maybe I can add another function to Tox.DateTime to returning DateTime.t()} | {:ambiguous, DateTime.t(), DateTime.t()} | {:gap, DateTime.t(), DateTime.t()}.

1 Like