Apply timezone shift to all helper functions in "DateHelper" module

I am quite new to Elixir.

I am working on a Phoenix/LiveView application that has a App.Utils.DateHelper module as part of its App.Utils that contains a bunch of functions like:

 def today do
    Date.utc_today()
  end
  
  def yesterday do
    Date.utc_today() |> Timex.shift(days: -1)
  end
....

I need to modify each of these functions and shift their returned value by some constant that depends on the user’s timezone.

I know how to fetch the user’s timezone front the front-end (e.g., on app.js), and how to resurface it in views, especially using get_connect_params (Fundamentals of passing data from the Plug connection to the LiveView - #2 by josevalim).

But I’d like to avoid having to add a “shift” parameters to each of these functions. That is, instead of something like

 def today(shift) do
      Date.utc_today() |> Timex.shift(hours: -shift)
    end
  
  def yesterday(shift) do
    Date.utc_today() |> Timex.shift(days: -1) |> Timex.shift(hours: -shift)
  end
....

I’d like to be able to import those functions into views directly with the proper shift parameter included:

 def today do
    Date.utc_today() |> Timex.shift(hours: -shift)
  end
  
  def yesterday do
    Date.utc_today() |> Timex.shift(days: -1) |> Timex.shift(hours: -shift)
  end
....

In the best of worlds, the shift parameter would be fetched and all the functions would be adjusted before the module is imported or aliased into a view. But I don’t think there is a way to surface the value of that parameter all the way from the front end to the defmodule definition itself?

Alternatively, I’d be ok with having to initialize it through something like a create_date_helper(shift) function at the top of each view where I need the date helpers.

I’ve tried to hack into macros, but can’t quite wrap my head around them and understand how they would apply here.

defmacro date_helper_creator(shift) do quote do def today do Date.utc_today() |> Timex.shift(hours: -unquote(shift)) end def yesterday do Date.utc_today() |> Timex.shift(days: -1) |> Timex.shift(hours: -unquote(shift)) end end end
does not seem to work.

Any suggestions on how I could achieve this?

I’m sure that’s not the answer you’re looking for, but why do that in the first place? Pass the shift parameter in explicitly. That’s the best option. (Implicit) global state is exactly what functional programming tries to avoid.

Macros don’t help there. Macros help you to create code out of other code at compile time. At runtime you’re generally not compiling anything, that’s done already.

6 Likes

I appreciate the challenge @LostKobrakai. Those date helpers are used over and over across our app. And they always have to be in the user’s local time. So I am trying to save us some repeated typing, errors (when we forget to include the shift parameter), and help readability. But I understand we may have to live with it if there’s no way around it or it’s too unidiomatic to do otherwise.

As mentioned, your idea of encoding this stuff into the module is a non-starter. Once a module is compiled, it’s compiled. This is done before the app is released, not on a per-request basis, so this isn’t even in the realm of possibility.

I would suggest making components that require a @user. Something like:

attr :at, :atom, values: [:current, :today, :yesterday, :maybe_some_others], default: :current
attr :user, MyApp.Accounts.user, required: true
attr :time, DateTime

def time(assigns) do
  time = # Do the math grabbing the timezone from `user`
         # and assigns.at and assigns.time to shift the time to right time
  assigns = assign(assigns, :time, time)

  ~H"""
  <time user={@user} at={@at} time={@time} />
  """
end

This forces you to always consider the current user (which you need to do anyway).

~H"""
<.time user={@current_user} time={some_datetime} />
"""
4 Likes

On the topic of reducing errors - by having the functions take a shift parameter, forgetting it would lead to compile errors (as opposed to runtime errors or wrong values being displayed if the shift is forgotten).

Side note: shifting Date.utc_today() by a timezone offset will not do what I assume you expect it to do?

def today(shift_hours) do
  DateTime.utc_now()
  |> Timex.shift(hours: shift_hours)
  |> Timex.to_date()
end

Would shift to the right date, taking the current time into account. (Also: there are time zones that don’t have whole-hour offsets)

And totally agreed on @sodapopcan 's suggestion. Making it impossible (as long as the right component is used) to display the time/date without taking the user’s timezone into consideration is a great idea.

2 Likes

It also doesn’t work correctly around shifts between DST/non-DST. Offsets are a crutch if calculations should be made. To correctly handle things a timezone is needed – offsets are not a timezone.

5 Likes

Right, thanks for the reminder. Don’t think I ever had a good time handling date(times) in applications. :sweat_smile:

Elixir does a lot of things right in that regard. The APIs generally expect you to have the right data and supply them explicitly. E.g. nowhere will it ask for an offset, but only for timezones. It can’t help though if that stuff is worked around.

Ya, I worked on a scheduling application and it was a massive pain. I also worked on an enterprise-y ERP where timezone mishaps were baked into the 15 year old ~1m LOC Rails app. Customers just accepted that twice per year things would get screwy for an hour :person_shrugging:

2 Likes

Yeah, improper handling of that stuff can happen so fast. I usually run by a few guidelines:

  • Use a timezone database supporting datetime calculations. Don’t calculate anything on your own or without one. Datetimes are not a continuous stream of values, so your own math will have problems.
  • Don’t expect the timezone database or their offsets to be constant. Things change over time / in the future.
  • UTC is not everything. Walltime can be important (or even more important). When in doubt store the timezone alongside your datetime.
  • Offsets cannot be used for calulations. They’re only useful as part of the datetime they come with, but have no guaranteed meaning for any other datetime.
8 Likes

Guess we should not say anything about the process dictionary? :speak_no_evil: (I’m already regretting saying it)

2 Likes

The process whationary?

2 Likes

It’s a valid tool for when you are programming explicitly around processes and don’t want to carry all the state around. But obviously, a lot of care has to be taken.

Yeah, it is one of those tools you shouldn’t use until you know you should use it… and even then you should probably not use it :sweat_smile:

Consider this is live view, which is one process for each connected user, using the process dictionary for time zone offset is not a bad idea.

Technically it could be several processes per connected user (several open tabs/devices/nested liveviews), though I’m not sure that has an impact on your point. I’ve never actually used the process dictionary myself.

It depends. I’d argue it’s not a great idea, because it will affect the liveview and all child live components at once, while those are generally treated as seprate entities. But they happen to run on the same process and would therefore share the process dictionary.

Yeah, I know :slight_smile: it is very tempting to use… but I think it is better to pass the timezone arround explicitly.

I know; I’d just use a socket assign myself. The OP has a code base that is already impure, so this maybe the least painful way.

To OP: I would question why you’d want to have a localized today() in the first place. All dates in the database shall be stored in UTC, all date calculations shall be in UTC, and one should covert date to local timezone in the last possible place, which is at rendering. And one should only call pure functions in the rendering layer.

If you’re trying to gradually support the shift in the code base, I think you can use the default argument syntax. On mobile right now so sorry for formatting, but something like:

def today(shift \\ 0) do
  # shift is 0 by default
  DateTime.utc_now()
  |> do_shift(hours: shift)
end

You can then use the same functions elsewhere in the code base without modification, and can slowly support the shift by adding the parameter

1 Like