[Phoenix LiveView] formatting date/time with local time zone

I wanted to format date/time with user's time zone. Here is what I learned.

Fetching time zone and locale

Browser side

Immediately after the initial render, we want to get the information on local time zone and locale from the browser and include them in the live socket params so that we can receive them in our LiveView process when the client-server connection is established.

Here are some convenient functions:

In /assets/js/app.js, we can simply add key-value pairs to the live socket params. Then we will be able to access them when the client is connected to the LiveView.

-let liveSocket = new LiveSocket('/live', Socket, { params: { _csrf_token: csrfToken } });
+let liveSocket = new LiveSocket('/live', Socket, {
+  params: {
+    _csrf_token: csrfToken,
+    locale: Intl.NumberFormat().resolvedOptions().locale,
+    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+    timezone_offset: -(new Date().getTimezoneOffset() / 60),
+  },
[info] CONNECTED TO Phoenix.LiveView.Socket in 112µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"_csrf_token" => "Ay8cCDsHZCFYBicSKTMHfi5EIjowK3sJHWHrqqVH4hcboKI8a1v_wB4g",
                "_mounts" => "0",
                "_track_static" => %{"0" => "http://localhost:4000/css/app.css",
                                     "1" => "http://localhost:4000/js/app.js"},
                "locale" => "en-US",
                "timezone" => "America/New_York",
                "timezone_offset" => "-5",
                "vsn" => "2.0.0"}

Server side (LiveView)

Now we want to fetch the time zone and locale from the socket params. One important thing is the socket params is only remain available during mount. Also we need to provide default values because the browser-dereived information is not available as of the initial render.

defmodule MnishiguchiWeb.TimezoneLive do
  use MnishiguchiWeb, :live_view

  @default_locale "en"
  @default_timezone "UTC"
  @default_timezone_offset 0

  @impl true
  def mount(_params, _session, socket) do
    socket =
      |> assign_locale()
      |> assign_timezone()
      |> assign_timezone_offset()

    {:ok, socket}

  defp assign_locale(socket) do
    locale = get_connect_params(socket)["locale"] || @default_locale
    assign(socket, locale: locale)

  defp assign_timezone(socket) do
    timezone = get_connect_params(socket)["timezone"] || @default_timezone
    assign(socket, timezone: timezone)

  defp assign_timezone_offset(socket) do
    timezone_offset = get_connect_params(socket)["timezone_offset"] || @default_timezone_offset
    assign(socket, timezone_offset: timezone_offset)


Formatting datetime

Once the time zone and locale are stored in our LiveView process, we want to transform the datetime values into the format that is friendly to the user. There are two convenient libraries for this purpose.

We add these librarie to our mix.exs:

   defp deps do
+      {:timex, "~> 3.6"},
+      {:ex_cldr_dates_times, "~> 2.0"},

then run mix deps.get.

According to the Cldr library documentation, all we need to set it up is just create a module like this:

defmodule Mnishiguchi.Cldr do
  @default_locale "en"
  @default_timezone "UTC"
  @default_format :long

  use Cldr,
    locales: ["en", "ja"],
    default_locale: @default_locale,
    providers: [Cldr.Number, Cldr.Calendar, Cldr.DateTime]

  @doc """
  Formats datatime based on specified options.

  ## Examples

      iex> format_time(~U[2021-03-02 22:05:28Z], locale: "ja", timezone: "Asia/Tokyo")
      "2021年3月3日 7:05:28 JST"

      iex> format_time(~U[2021-03-02 22:05:28Z], locale: "ja", timezone: "America/New_York")
      "2021年3月2日 17:05:28 EST"

      iex> format_time(~U[2021-03-02 22:05:28Z], locale: "en-US", timezone: "America/New_York")
      "March 2, 2021 at 5:05:28 PM EST"

      # Fallback to ISO8601 string.
      iex> format_time(~U[2021-03-02 22:05:28Z], timezone: "Hello")

  @spec format_time(DateTime.t(), nil | list | map) :: binary
  def format_time(datetime, options \\ []) do
    locale = options[:locale] || @default_locale
    timezone = options[:timezone] || @default_timezone
    format = options[:format] || @default_format
    cldr_options = [locale: locale, format: format]

    with time_in_tz <- Timex.Timezone.convert(datetime, timezone),
         {:ok, formatted_time} <- __MODULE__.DateTime.to_string(time_in_tz, cldr_options) do
      {:error, _reason} ->
        Timex.format!(datetime, "{ISO:Extended}")

Then we can that format_time function anywhere.

Adding loading icon

This is technically optional, but because we do not know the browser's information as of the initial render, we have to fallback to default values. So the use will see the weird effect of time format changing from the default format to local format immediately when the connection is established.

This guy uses a different approach to this issue but I chose to simply hide the contents until the LiveView is connected. I believe the UI will still look natural by showing a nice loading icon. I found the Single Element CSS Spinners library handy.

<%= unless connected?(@socket) do %>
  <div style="min-height:90vh">
    <div class="loader">Loading...</div>
<% else %>

  <!-- contents -->

<% end %>