Motivation:
I want to write
<.relative_datetime timestamp={my_schema.inserted_at} />
and to get this in my browser:
while other users get, say, this (Arabic) in their browser:
I wanted to render a NaiveDateTime
as something like “30 seconds ago”. I wanted it to live-update. I wanted to use the user’s chosen locale.
Normally I would “live update” something like this on the client side but I wanted to use Cldr
locale features for easy translating.
Turning a NaiveDateTime into a nice string can be done server-side like this:
@spec relative_string(DateTime.t()) :: String.t() | nil
@spec relative_string(DateTime.t(), DateTime.t()) :: String.t() | nil
def relative_string(dt) do
relative_string(dt, DateTime.utc_now())
end
def relative_string(dt, now) do
locale = Gettext.get_locale(MyApp.Gettext)
opts = [relative_to: now, locale: locale]
case MyApp.Cldr.DateTime.Relative.to_string(dt, opts) do
{:ok, ts_string} -> ts_string
_ -> nil
end
end
Since we want this to live-update, there needs to be some kind of periodic recalculation of this string. This could be done using send
inside a LiveView process but assigning the timestamps, maybe in a map, on the socket, would be very unpleasant.
So I used a live component, wrapped in a dead view:
defmodule MyAppWeb.Components.RelativeDatetime do
@moduledoc """
Display timestamps in human-friendly language
It will push updates at the following intervals:
- 1 second for the first minute
- 1 minute for the first hour
- 5 minutes for the first day
- 1 hour for the first week
- 1 day for the first month
- 1 week for the first year
"""
use Phoenix.Component
alias MyApp.Utils.DateTimeUtils
alias MyAppWeb.Components.RelativeDatetimeLiveComponent, as: LC
# timestamp should be a DateTime or a NaiveDateTime
attr :timestamp, :any, default: nil
def relative_datetime(assigns) do
case DateTimeUtils.assured_datetime(assigns.timestamp) do
%DateTime{} = dt ->
id = "relative-datetime-#{System.unique_integer()}"
assigns = assign(assigns, %{dt: dt, id: id})
~H"""
<.live_component id={@id} module={LC} dt={@dt} />
"""
nil ->
~H"""
"""
end
end
end
defmodule MyAppWeb.Components.RelativeDatetimeLiveComponent do
@moduledoc false
use Phoenix.LiveComponent
alias MyApp.Utils.DateTimeUtils
def update(assigns, socket) do
socket
|> assign(Map.take(assigns, [:dt, :id]))
|> assign_ts_string_and_period_ms()
|> then(&{:ok, &1})
end
def render(assigns) do
~H"""
<div id={@id} phx-hook="PeriodicUpdateHook" data-period-ms={@period_ms}>
<%= @ts_string %>
</div>
"""
end
def handle_event("update", _params, socket) do
socket
|> assign_ts_string_and_period_ms()
|> then(&{:noreply, &1})
end
@minute_s 60
@hour_s 60 * @minute_s
@day_s 24 * @hour_s
@week_s 7 * @day_s
@month_s 30 * @day_s
@year_s 365 * @day_s
@one_second_ms 1000
@one_minute_ms 60 * @one_second_ms
@five_minutes_ms 5 * @one_minute_ms
@one_hour_ms 60 * @one_minute_ms
@one_day_ms 24 * @one_hour_ms
@one_week_ms 7 * @one_day_ms
defp build_period_ms(dt, now) do
diff = DateTime.diff(now, dt)
cond do
diff < @minute_s -> @one_second_ms
diff < @hour_s -> @one_minute_ms
diff < @day_s -> @five_minutes_ms
diff < @week_s -> @one_hour_ms
diff < @month_s -> @one_day_ms
diff < @year_s -> @one_week_ms
end
end
defp assign_ts_string_and_period_ms(socket) do
%{dt: dt} = socket.assigns
now = DateTime.utc_now()
ts_string = DateTimeUtils.relative_string(dt, now)
period_ms = build_period_ms(dt, now)
assign(socket, %{period_ms: period_ms, ts_string: ts_string})
end
end
with accompanying JS hook
const PeriodicUpdateHook = {
mounted() {
const pushEventToComponent = (event, payload) => {
this.pushEventTo(this.el, event, payload);
};
const pushUpdate = () => pushEventToComponent("update", {});
this.refreshPeriod = () => {
const { periodMs } = this.el.dataset;
if (periodMs !== this.periodMs) {
this.periodMs = periodMs;
this.intervalID && clearInterval(this.intervalID);
this.intervalID = setInterval(pushUpdate, periodMs);
}
};
this.refreshPeriod();
},
updated() {
this.refreshPeriod();
},
destroyed() {
this.intervalID && clearInterval(this.intervalID);
},
};
export { PeriodicUpdateHook };
Note that the update frequency drops off as the timestamp becomes older. This has to be achieved using the updated
callback in the hook and re-reading the dataset attributes, where the setInterval
delay is tied to assign period_ms
.
Note also that the conversion from NaiveDateTime to UTC DateTime is done only once in the “dead view” while the live component itself expects a DateTime. The motivation here is to discourage writing code that might require converting NaiveDateTime to DateTime on every re-render. Small things.
I do not like my use of System.unique_integer()
in the component ID. I use this function way too much. It somehow feels reckless.
I think this is a good use case for https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html#module-cost-of-live-components
Any feedback is very welcome.