[Phoenix LiveView] date/timeをtime zoneとlocaleで翻訳

[2021/03/05(金) 23:59〜開催のautoracex #14](https://autoracex.connpass.com/event/206776/)での成果です。

Phoenix LiveViewアプリ上のdate/timeをブラウザから取得したtime zoneとlocaleにより翻訳する方法について勉強しました。

経緯としては、過去数週間、autoracexの主催者@torifukukaiouさんの記事を参考に、趣味で温度湿度データを表示するPhoenix LiveViewアプリに取り組んでおり、そこに表示する時間のフォーマットを現地化してみたいなーと思っていたことです。全てUTCで同じフォーマットではなく、アメリカ東海岸ではアメリカ東海岸の、日本では日本やりかたで時間を表示したかったのです。

https://qiita.com/torifukukaiou/items/5876bc4576e7b7991347

```elixir
# ある時間
~U[2021-03-02 22:05:28Z]

# 日本にいるユーザー向け
"2021年3月3日 7:05:28 JST"

# アメリカ東海岸のユーザー向け
"March 2, 2021 at 5:05:28 PM EST"
```

## time zoneとlocaleの取得

### ブラウザ側

LiveViewでは、接続前と接続後の２回サーバ側でレンダリングされます。一回目レンダリングのあとにブラウザ側のJavascriptでtime zoneとlocaleを取得し、それらをLiveSocketのparamsに追加します。そうすることで、接続後のレンダリングでそれらの値にアクセスできます。

いろいろやり方があるようですが。僕は下記の関数を使いました。

![](https://user-images.githubusercontent.com/7563926/110216805-1a2c7680-7e7f-11eb-8d66-66c1575e6a7a.png)

`/assets/js/app.js`のLiveSocketに任意のkey-valueペアをparamsとして追加します。

```diff
-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"}
```

### サーバ(LiveView)側

サーバ(LiveView)側では、socketのparams経由でtime zoneとlocaleを受け取ります。一つ重要なことは、[socketのparamsはLiveView接続後のマウント時のみにアクセスできる](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#get_connect_params/1)ことです。

```elixir
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 =
      socket
      |> assign_locale()
      |> assign_timezone()
      |> assign_timezone_offset()

    {:ok, socket}
  end

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

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

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

  ...
```

## datetimeフォーマット

time zoneとlocaleをLiveViewプロセスの状態に保存したので、あとはそれを使用してどうdatetimeをユーザ現地のフォーマットに加工するかの問題です。この目的にはこれらのライブラリが使えます。

- [Timex](https://github.com/bitwalker/timex) - a rich, comprehensive Date/Time library for Elixir projects, with full timezone support via the `:tzdata` package
- [Cldr](https://github.com/elixir-cldr/cldr_dates_times) - an Elixir library for the [Unicode Consortium’s Common Locale Data Repository (CLDR)](http://cldr.unicode.org/)

`mix.exs`に追加して、いつもの通り`mix deps.get`します。

```diff
   defp deps do
     [
       ...
+      {:timex, "~> 3.6"},
+      {:ex_cldr_dates_times, "~> 2.0"},
       ...
     ]
   end
```

[Cldrライブラリのドキュメント](https://github.com/elixir-cldr/cldr_dates_times)によると、設定はシンプルで、こんな感じにモジュールを作るだけでうまくいきました。これでアプリのどこからでも一貫したdatetimeのフォーマットができます。

```elixir
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 datetime 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")
      "2021-03-02T22:05:28+00:00"

  """
  @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
      formatted_time
    else
      {:error, _reason} ->
        Timex.format!(datetime, "{ISO:Extended}")
    end
  end
end
```

## ローディングアイコン

これは別になくてもよいのですが、ここまでのコードで一つ問題があります。１回目のレンダリング時点ではまだブラウザでtime zoneとlocaleの取得は行っていません。ですのでデフォルトの値でとりあえず仮のレンダリングをします。その結果、一回目と二回目のレンダリングでフォーマットが異なるという変な挙動を起こしてしまいます。一瞬ですがユーザーには不自然に見えます。

[他の方法](https://alex-min.fr/live-view-browser-timezone/)もあるみたいですが、僕はLiveViewが接続されるまでの間、単にローディングアイコンを表示してコンテンツを隠すという方針にしました。実装とメンテが楽でかつ確実に効くので。[Single Element CSS Spinners](https://projects.lukehaas.me/css-loaders/)のアイコンが気に入ってます。

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

  <!-- contents -->

<% end %>
```

以上

## 資料

- [Phoenix.LiveView connected?/1](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#connected?/1)
- [Phoenix.LiveView get_connect_params/1](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#get_connect_params/1)
- [Managing browser timezones to display dates with Phoenix Live View](https://alex-min.fr/live-view-browser-timezone/) by Alex-Min
- [Single Element CSS Spinners](https://projects.lukehaas.me/css-loaders/) by lukehaas
- [Determine a user's timezone | Stackoverflow](https://stackoverflow.com/questions/13/determine-a-users-timezone)
- [elixir-cldr/cldr_dates_times](https://github.com/elixir-cldr/cldr_dates_times)
- [bitwalker/timex](https://github.com/bitwalker/timex)