slouchpie
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 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.
Trending in Guides/Tuts
Other Trending Topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #blog-post
- #phoenix_html
- #iex
- #graphql
- #ai
- #genstage
- #elixirconf-us
- #websockets
- #supervisor
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #metaprogramming
- #security
- #hex










Showing Posts 1 to 9- Show Best Posts
- Show All Posts (oldest first)
- Show All Posts (newest first)
FlyingNoodle
Why not use Intl.RelativeTimeFormat - JavaScript | MDN ?
Edit: Sry for necro. For some reason this popped up as the newest topic in my discourse?
slouchpie
I did not know about this JS function.
How would you build the inputs to
rtf.format, e.g.rtf.format(-1, "day")?That is, if our source data is a
%NaiveDateTime{}struct (or a DateTime), how would we get the correct value and unit to represent the relative time?FlyingNoodle
A little bit of JS (copyright: chatgpt):
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
“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
timeAgois 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.
FlyingNoodle
If you pass UTC time then a relative time string is “always” the same? I mean yh you can get some inaccuracy when you write “5 days ago” when it should have been “4 days and 21 hours ago” but if you use relative time then… does that matter?
You do you but I wouldn’t do this over the socket. If you have a list view with 100 items, every item has 2 columns with relative time you might spam a lot of messages.
Of course thats totally doable but it wouldn’t be my preference I think.
slouchpie
You’re right that with 100 items with recent timestamps, there will be possibly 100 websocket messages per second. LiveView is pretty good at this exact thing but I understand why you would prefer to do it in just JS.
If you write an example that uses JS, please share it here. I imagine other people share your preference and it could be a starting point for other people to then improve on it. I am not egoically attached to my implementation. My main motivating factor is fear of working with date-time in JS.
re: the “4 days and 21 hours ago” vs “5 days”. You’re right this is probably not that bad. But it would be really bad if it was supposed to say “2 minutes ago” (past) and it actually said “in 8 hours” (future), which is the kind of timezone problem I don’t like debugging in JS.
water
hi
I can’t find this declaration. What does it do?
slouchpie
Here you go:
It just takes either a NaiveDateTime (no timezone) or a DateTime and returns a DateTime (timezone). It assumes the NaiveDateTime is in UTC timezone.
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.