Managing browser timezones to display dates with Phoenix Live View

Dates, timezones and locales

Github Checkout the demo project on GitHub

I’m currently building a web version of my personal finance application, for that, I’ve chosen to use Phoenix Live View, it’s the perfect tech for my use case.

In this finance web application, you do have a lot of dates to display such as when you actually spent the money. Users (including myself) can also travel so the dates needs to be displayed according to the current browser timezone.

There’s a simple way to get the timezone in the browser:

1
new Date().getTimezoneOffset(); // returns the timezone offset in minutes

So here are the main things we need to do:

  • Send the browser timezone to the Live View
  • Store the timezone in the session. This is needed for the server side rendering since we also want to display the right date even on the server side before the live view mounts. If we don’t do this, the user is going to see the wrong date first and then the view will be re-rendered properly once the socket connects, this isn’t a great UX and will look janky.
  • Retreive the timezone either from the Live socket parameters or the session depending if we’re on the server side rendering.

Sending the timezone

Luckily for us, there’s a simple way to send parameters on the initialisation of the Live Socket:

1
2
3
4
5
let liveSocket = new LiveSocket("/live", Socket, {
    params: {
        timezone: - (new Date().getTimezoneOffset() / 60)
    }
})

That’s as simple as that! This enables us to receive the timezone when the live view is connected, Phoenix.LiveView.get_connect_params/1 can then be used to retreive that data if the socket is connected.

The first part being completed, we need to create a small controller which stores the timezone into the session:

1
2
3
4
5
6
7
defmodule MavioWeb.SessionSetTimezoneController do
  use MavioWeb, :controller

  def set(conn, %{"timezone" => timezone}) when is_number(timezone) do
    conn |> put_session(:timezone, timezone) |> json(%{})
  end
end

On the first page load, we need to call this controller to store the timezone in the session, for that a small bit of Javascript is needed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export default function sendTimezoneToServer() {
    const timezone = - (new Date().getTimezoneOffset() / 60);
    let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")


    if (typeof window.localStorage != 'undefined') {
        try {
            // if we sent the timezone already or the timezone changed since last time we sent
            if (!localStorage["timezone"] || localStorage["timezone"].toString() != timezone.toString()) {
                var xhr = new XMLHttpRequest();
                xhr.open("POST", '/api/session/set-timezone', true);
                xhr.setRequestHeader("Content-Type", "application/json");
                xhr.setRequestHeader("x-csrf-token", csrfToken);
                xhr.onreadystatechange = function () {
                    if (this.readyState === XMLHttpRequest.DONE && this.status === 200) {
                        localStorage['timezone'] = timezone.toString();
                    }
                };
                xhr.send(`{"timezone": ${timezone}}`);
            }
        } catch (e) { }
    }
}

I’m using XMLHttpRequest since I also need to support older browsers but you might want to use window.fetch or Axios which have much nicer apis.

On the Live View

On the Live View we just retreive the timezone, either from the session or from the socket parameters depending of what we have available:

1
2
3
4
5
6
7
8
9
10
  def get_timezone(socket, session) do
    if Phoenix.LiveView.connected?(socket) do
      case Phoenix.LiveView.get_connect_params(socket) do
        %{"timezone" => timezone} -> timezone
        _ -> session["timezone"] || 0
      end
    else
      session["timezone"] || 0
    end
  end

At this point you are probably thinking why I’m bothering with the session if we can send this data every time the socket is initialized? There’s a very simple reason for that, Live views are rendered server side before the socket is actually connected, if we did not do that session step, the dates would simply be rendered with the wrong timezone the first time and then “corrected” once the socket is actually connected, that would be a poor user experience.

I’ve then created a simple Live view helper to display the date according to the timezone and the local:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  def to_datestring(date, _locale, _timezone) when date == nil or date == "" do
    ""
  end

  def to_datestring(date, locale, timezone) when is_binary(date) do
    {:ok, parsed_date, _} = DateTime.from_iso8601(date)

    {:ok, str} =
      Mavio.Cldr.DateTime.to_string(parsed_date |> Timex.shift(hours: timezone), locale: locale)

    str
  end

  @spec to_datestring(DateTime.t() | String.t(), String.t(), integer()) :: String.t()
  def to_datestring(date, locale, timezone) do
    {:ok, str} =
      Mavio.Cldr.DateTime.to_string(date |> Timex.shift(hours: timezone), locale: locale)

    str
  end

This helper supports using strings or actual DateTime objects, I’m using the excellent Timex library to shift the timezone offset and Cldr for internationalization.

And here we go, our objective is completed! Dates can now be displayed in multiple languages and multiple timezones!

Up Next

Thinking Elixir podcast: Using LiveView and Hooks!

Participating to the Thinking Elixir podcast!

A hook for handling very large lists with Phoenix Live View


Looking for More?

More tech articles are coming soon!