Adding days with a TZ is confusing

This question is more about semantics. Adding a :day to the DateTime instance changes the time value, which might be slightly confusing.

iex(1)> DateTime.new!(~D[2024-04-06], ~T[23:59:00], "Australia/Sydney")
...(1)>   |> IO.inspect()
...(1)>   |> DateTime.add(1, :day)
#DateTime<2024-04-06 23:59:00+11:00 AEDT Australia/Sydney>
#DateTime<2024-04-07 22:59:00+10:00 AEST Australia/Sydney>

Yes, adding the exact number of seconds (86_400) multiplied by the number of days results in this, but DateTime(dt, 5, :day) usually means the developer wants to get the very same hh:mm:ss time back.

Consider a ToDoApp (huh) example. If I am after producing a Stream of recurring events, I am surely to expect it won’t change the Time part alongside with a DST change.

I discovered this when people complained about Tempus.Slots.Stream.recurrent/3 is broken in an aforementioned way. I fixed it by reassuring Time part to stay intact in the produced stream elements, but it looks like a hack and I am positive, DateTime.add(
, 
, :day) should not corrupt the Time anyway.

Thought?

4 Likes

This function relies on a contiguous representation of time, ignoring the wall time and timezone changes. For example, if you add one day when there are summer time/daylight saving time changes, it will also change the time forward or backward by one hour, so the elapsed time is precisely 24 hours.

This is called out in the docs as the intended behaviour.

If you want a continuous stream of datetimes at the same hour you can use a date and Date.add and turn those dates into datetimes with a time. Though you might run into invalid/duplicate datetimes this way if a date+time combination is in a DST change switch.

4 Likes

The docs for the new DateTime.shift/3 landing in 1.17 addresses this specifically and offers a suggestion using NaiveDateTime. It looks like this works:

iex(1)> Mix.install([:tz])
:ok
iex(2)> Calendar.put_time_zone_database(Tz.TimeZoneDatabase)
:ok
iex(3)> dt = DateTime.new!(~D[2024-04-06], ~T[23:59:00], "Australia/Sydney")
#DateTime<2024-04-06 23:59:00+11:00 AEDT Australia/Sydney>
iex(4)> dt |> DateTime.to_naive() |> NaiveDateTime.add(1, :day) |> DateTime.from_naive(dt.time_zone)
{:ok, #DateTime<2024-04-07 23:59:00+10:00 AEST Australia/Sydney>}

Edit to note that the above isn’t using anything in 1.17 and was copied from a 1.16 REPL.

3 Likes

Thanks @LostKobrakai, @zachallaun,

I know how to overcome it. Also, I know this behaviour is intended. I started this topic with “This question is more about semantics,” and that​’s what all this is about.

I find it extremely confusing that adding an integer number of days to the instance of DateTime results in the change of hour field in the struct. If that’s only me, I am good. But I am positive if you stop a hundred persons on the street to ask “now it’s Saturday, 3PM. This night there will be a DST shift. What would be the result of adding exactly one day to now?”—99 if not 100 of them would answer 3PM.

Please note, that the above does not apply to adding hours, let alone seconds.

The laymans opinion doesn’t really help in a domain full of real life footguns a layman is usually not aware of. As mentioned you cannot add a day to a datetime while keeping the hour value the same without it being able to produce invalid datetimes. DateTime.add cannot fail though or adding :day (together with hour and minute) as a unit would’ve been a BC breaking change. Personally I probably would’ve even skipped :day for exactly this discussion.

If you still want to attempt this you simply need to do it using APIs of DateTime, which can return you errors around those invalid datetimes and decide for a way to handle those for your specific usecase.

E.g. the following naive datetime is invalid, as it is never observed, even though the day before and after do have an observed datetime at 02:30:00. So there needs to be a decision made if the invalid one is skipped, shifted to the last observed datetime before or the next after. Similar thing happens when shifting the other direction where a naive datetime would be observed twice, once with DST offset and once without.

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

That I’d strongly appreciate, but once we already have it, we might be more adaptive to laymans’ opinion :slight_smile:

Look, we already round days down to the nearest valid date when shifting by month in 1.17. The API is already not generic. We are already treating “add 1 month” in layman’s terms. What would be wrong with treating “add 1 day” in the very same manner? Once it’s documented it’s ok, that’s what you were saying in the first comment, right?

Fwiw, I’d share why it made me upset. While working on Tempus, which is a library to represent time slots, I dug a lot, and the most inspiring was tempo library by @kip, which is great, but my goal was slightly different. I was more after infinite streams of intervals, and their interop, like joins, intersections, etc. I needed a way to tell if this infinite slot stream covers that particular time. That’s why the algebra I’ve chosen is slightly different (although similar.) Still, I wanted the clients to face as few quirks of the interval mess as possible.

And the aforementioned “add several days” was literally the only thing I was not ready to (and I was ready to enter a hell, trust me.) Anyway.

YMMV: 40 out of 100 of them would also tell you that DST helps solar panels make more power, because there’s “more sun”. :stuck_out_tongue:

To @LostKobrakai’s example, only a developer would tell you “there’s no such time as one day after 2:30AM Saturday morning if the Sunday is the DST switchover”

1 Like

With all due respect, that’s not what DateTime tells us. It happily adds one day, and that’s what bugs me, because I believe it does it wrong.

DateTime.shift could make that decision, sure. But DateTime.add adds a certain (and fixed per amount/unit) amount of seconds to the supplied DateTime. That’s a different task. The issue is that it allows for :day with a constant of 86400 seconds when days are not always that long. That’s also the reason why :week, :month, :year weren’t added to DateTime.add, but handled into the new DateTime.shift.

2 Likes

Sure. On the other hand, even add(
, 
, :second) might suffer the same disease when next to the leap second, so it’s all cumbersome.

Not modeling leapseconds doesn‘t leave you in a state where you miss a (naive) datetime or run into it twice though - which is what happens if you don‘t handle the dst change complexities.

1 Like