Relative human-friendly timestamp, live-updating, using locale

Motivation:

I want to write

<.relative_datetime timestamp={my_schema.inserted_at} />

and to get this in my browser:

en

while other users get, say, this (Arabic) in their browser:

ar

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.

3 Likes