slouchpie

slouchpie

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 DateTime 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.

Most Liked

FlyingNoodle

FlyingNoodle

A little bit of JS (copyright: chatgpt):

// one formatter you can re-use for every call (change "en" to the user’s locale)
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });

function timeAgo(date, opts = {}) {
  const now = opts.now ?? Date.now();            // for testing / freeze-time
  const diff = date - now;                       // milliseconds (±)

  /** ordered from largest to smallest to keep the result human-friendly */
  const TABLE = [
    ["year",   1000 * 60 * 60 * 24 * 365],
    ["month",  1000 * 60 * 60 * 24 * 30],        // ≈ 30d  (good enough)
    ["week",   1000 * 60 * 60 * 24 * 7],
    ["day",    1000 * 60 * 60 * 24],
    ["hour",   1000 * 60 * 60],
    ["minute", 1000 * 60],
    ["second", 1000],
  ];

  for (const [unit, ms] of TABLE) {
    const value = diff / ms;
    if (Math.abs(value) >= 1)
      return rtf.format(Math.round(value), unit);
  }
  return rtf.format(0, "second");                // < 1 s difference
}

// ─── usage ──────────────────────────────────────────────────────────────
console.log(timeAgo(Date.now() - 4 * 60 * 60_000));  // "4 hours ago"
console.log(timeAgo(Date.now() + 3 * 24 * 60 * 60_000)); // "in 3 days"
console.log(timeAgo(Date.now() - 5 * 30 * 24 * 60 * 60_000)); // "5 months ago"

I would write the datetime to the data attribute and then write a bit of JS that replaces the text of all elements that have [data-relative-datetime] set.

I mean yeah you can do this using liveview but this feels like something where I should use the built in browser translation stuff, write a bit of JS and then never worry about it again.

slouchpie

slouchpie

“never worry about it again” is the opposite of my (limited) experience working with even simple date-time stuff in JS.

I think your code would have issues in reality. Date.now will use the browser’s timezone, with no regard for whatever the user’s timezone is in the app. So you would have to make certain that the date passed to timeAgo is converted to the browser’s timezone.

I am certain it could be solved with JS. Personally, I am a lot more comfortable doing date-time manipulation in Elixir.

rhcarvalho

rhcarvalho

There’s also this GitHub - github/relative-time-element: Web component extensions to the standard <time> element. · GitHub I use in a LiveView app. Install, import in app.js, use the HTML element passing a timestamp from an assign.

Where Next?

Popular in Guides/Tuts Top

Logan
Here is an example of a Mix application that utilizes Cowboy to handle websocket connections. If anyone has an idea about making this wor...
New
tomciopp
TLS 1.3 has been out for a little over a year now, but it has been unavailable in Phoenix due to erlang’s handling of ssl. With the most ...
New
anuragg
Hi everyone! I’m the founder of Render, a new cloud provider with native support for Elixir. When we launched Elixir support the most po...
New
tobleron
Ok, so I am so excited to share with you the most interesting setup I have made for Elixir/Phoenix today. Why? Because if you trust me, i...
New
hauleth
Some time ago someone suggested me to write article about how I have configured my Vim to work with Elixir, now there it is: https://med...
New
New
yeshan333
vfox (version-fox) is a popular general version management tool write in Go, and the plug-in mechanism uses Lua to achieve extensibility....
New
AstonJ
With a few questions relating to this recently, I wonder if it might help having a thread where we share how we’ve set up Elixir/Erlang/P...
New
emilsoman
Hello, While answering a StackOverflow question on how to debug an elixir node running remotely, I thought it may be helpful to write a ...
New
tonydang
I recently got inertia-phoenix (an Inertia.js adapter for Phoenix) working with Svelte. Setting up the server-side rendering (SSR) with S...
New

Other popular topics Top

vertexbuffer
Hello, can anybody help here..? I have a list of players and I what to delete an element, but every for loop the list is reverting to ori...
New
9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
New
lastday4you
I wanted to check elixir version in phoenix because i found that my elixir is 1.5 but when i use Enum.chunk_by it said the function is un...
New
Darmani72
If I have a post route which an argument: post /my_post_route/:my_param1, MyController.my_post_handler How would get the post params ...
New
baxterw3b
Hi guys, i’m new in the Elixir world, and i have to say, that i love it! i’m having some problem to understand anonymous functions with ...
New
chrismccord
Phoenix 1.4.0 released Phoenix 1.4 is out! This release ships with exciting new features, most notably with HTTP2 support, improved deve...
688 31194 112
New
fireproofsocks
Forgive me if this is obvious, but how does one delete a database record WITHOUT selecting it first? Ecto.Repo — Ecto v3.14.0 has exampl...
New
romenigld
I am trying to run a deploy with docker and I successfully runned with this command: docker build -t romenigld/blog-prod . but when I t...
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
jaysoifer
Is there a way to rollback a specific migration and only that one (“skipping” all the other ones)? Would mix ecto.rollback -v 200809061...
New

We're in Beta

About us Mission Statement