LoginSignup
3
0

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

Last updated at Posted at 2021-03-06

2021/03/05(金) 23:59〜開催のautoracex #14での成果です。

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

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

# ある時間
~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では、接続前と接続後の2回サーバ側でレンダリングされます。一回目レンダリングのあとにブラウザ側のJavascriptでtime zoneとlocaleを取得し、それらをLiveSocketのparamsに追加します。そうすることで、接続後のレンダリングでそれらの値にアクセスできます。

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

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

-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接続後のマウント時のみにアクセスできることです。

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をユーザ現地のフォーマットに加工するかの問題です。この目的にはこれらのライブラリが使えます。

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

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

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

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

ローディングアイコン

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

他の方法もあるみたいですが、僕はLiveViewが接続されるまでの間、単にローディングアイコンを表示してコンテンツを隠すという方針にしました。実装とメンテが楽でかつ確実に効くので。Single Element CSS Spinnersのアイコンが気に入ってます。

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

  <!-- contents -->

<% end %>

以上

資料

3
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0