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をユーザ現地のフォーマットに加工するかの問題です。この目的にはこれらのライブラリが使えます。
-
Timex - a rich, comprehensive Date/Time library for Elixir projects, with full timezone support via the
:tzdata
package - Cldr - an Elixir library for the Unicode Consortium’s Common Locale Data Repository (CLDR)
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 %>
以上