Available for Elixir Consulting.

Phoenix LiveView: Presenting DateTime in User's Time Zone

Posted on

This past weekend, I added a feature to Flick (RankedVote.app) where we now present domain-specific DateTime values, like published_at and closed_at, on the live view page using the user’s time zone. I thought I’d capture some notes on how this was accomplished, some known limitations, ideas to solve those in your own work, and a set of resource links to learn more.

Elixir time zone basics

Out of the box, when working with Elixir DateTime values, you can only create values relative to the Etc/UTC time zone. If you want to represent values in other time zones, you will need a time zone database.

The Elixir docs discuss this and link a few options. For this post and the Flick project, I choose tzdata, and the following paragraphs will reference it specifically. However, the Elixir runtime is very flexible if you prefer another.

To add the dependency to your project, you’ll add it to the deps list in the mix.exs file.

# mix.exs
defp deps do
  [  
    # ...
    {:tzdata, "~> 1.1"}
  ]
end

Then, you’ll need to configure it as your :time_zone_database in the config/config.exs file.

# config/config.exs
config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase

With that in place, you can create values in a time zone.

iex> DateTime.new(~D[2016-05-24], ~T[13:26:08.003], "America/New_York")
{:ok, #DateTime<2016-05-24 13:26:08.003-04:00 EDT America/New_York>}

The user’s time zone

When I say User here, I refer to the web browser client requesting a web page from our server.

Sadly, when displaying a DateTime value in this user’s response, we are hampered by the fact that we do not know their time zone. The time zone is not included with the HTTP request.

Assuming the browser renders the page in an environment that executes JavaScript, the most approachable way to get this is via a function call like:

Intl.DateTimeFormat().resolvedOptions().timeZone

MDN Reference for DateTimeFormat.

Aside: Curiously, the time zone and locale details of the user’s environment are not part of a permission prompt flow even though they leak sensitive data IMO. If viewing the page, assume you are giving up that info.

So, how do we use this relative to our live view page?

The first thing we will do is edit our assets/js/app.js file. We want to query the browser for the time zone and send it to LiveView via the params of the LiveSocket.

// assets/js/app.js
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")

// Add this.
let time_zone = Intl.DateTimeFormat().resolvedOptions().timeZone 

let liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  // And `time_zone` here.
  params: { _csrf_token: csrfToken, time_zone: time_zone } 
})

From your live view module’s mount/3 function, you can access this time_zone value like:

  def mount(params, _session, socket) do
    # ...

    # The `time_zone` value will only be available when the live view is
    # rendering a `connected?/1` socket, so make sure to define a default.
    time_zone = get_connect_params(socket)["time_zone"] || "UTC"

    # ...
  end

Once you have the time zone value, any domain-specific DateTime you are storing in UTC can be converted using DateTime.shift_zone/2 for display to the user.

For the needs of my side project, I made a dedicated module called Flick.DateTimeFormatter, which helps me shift the DateTime values and format them to an expected style. It looks something like:

  def display_string(date_time_value, time_zone) do
    case DateTime.shift_zone(date_time_value, time_zone) do
      {:ok, date_time_in_time_zone} ->
        Calendar.strftime(date_time_in_time_zone, "%B %-d, %Y %-I:%M %p %Z")

      {:error, reason} ->
        {:error, reason}
    end
  end

With this in place, I can display a DateTime value from any live view in the user’s time zone.

A full implementation can be found in the Flick PR if you want more reference materials.

This is a half solution.

You might recall in a code comment above, I said:

The `time_zone` value will only be available when the live view is
rendering a `connected?/1` socket, so make sure to define a default.

You might also recall that when a user requests a URL, the live view first renders a non-connected DOM state. This DOM is delivered to the browser, and only then is a WebSocket established, and thus the live view becomes “connected”.

The outcome of this and the design of our solution is that the user will see the DateTime value rendered in UTC first (our default) and then flash into the browser’s time zone as the WebSocket becomes connected.

That kind of sucks, but for the simple needs of Flick, this felt acceptable and is where I left it.

Designing a more complete solution.

In a previous project, I built a meetup group platform. It had to display lots of DateTime values, and this kind of flashing would not have been acceptable.

For that project, we created a stack of fallback time zones.

  1. When a user created their meetup group, they defined a default display time zone for the group. This was our fallback default.
  2. When a web visitor was on the page, we executed JavaScript that would PUT the observed time zone we saw and then on the backend inside a standard controller we would store the time zone inside the session. This would be our ideal default, and would generally be available on everything except the user’s first page load of the site.

A Hook-based approach.

LittleAccountOfCalm of Reddit also suggests:

Pardon me, but why not render a <local-time phx-hook="LocalTime" id={@id} class="invisible">{@date}</local-time component, that has a js-hook like:

Hooks.LocalTime = {
  mounted() {
    this.updated();
  },
  updated() {
    let dt = new Date(this.el.textContent);
    this.el.textContent = Intl.DateTimeFormat("default", {
      dateStyle: "medium",
      // timeStyle: "short",
    }).format(dt);
    this.el.classList.remove("invisible");
  }
}

I recall seeing approaches like this before and you correct that this is just as reasonable of a solution for Flick’s needs. I vaguely recall requirements that made it a non-option for my other project (maybe due to time zone presentation in email copy) but a great addition to the post. Thanks for sharing!

Other Resources

I’ll leave you out with some related resources. Good coding, and if you have any questions, reach out.