Current section
Files
Jump to
Current section
Files
lib/human_time.ex
defmodule HumanTime do
@moduledoc """
A module to convert time intervals into human-readable strings with support for localization.
"""
@translations %{
"en" => %{
"ago" => "{time} ago",
"from_now" => "{time} from now",
"second" => " second",
"minute" => " minute",
"hour" => " hour",
"day" => " day",
"month" => " month",
"year" => " year"
},
"fr" => %{
"ago" => "il y a {time}",
"from_now" => "dans {time}",
"second" => " seconde",
"minute" => " minute",
"hour" => " heure",
"day" => " jour",
"month" => " mois",
"year" => " an"
},
"es" => %{
"ago" => "hace {time}",
"from_now" => "en {time}",
"second" => " segundo",
"minute" => " minuto",
"hour" => " hora",
"day" => " día",
"month" => " mes",
"year" => " año"
},
"zh" => %{
"ago" => "{time}前",
"from_now" => "{time}后",
"second" => "秒",
"minute" => "分钟",
"hour" => "小时",
"day" => "天",
"month" => "月",
"year" => "年"
},
"pt" => %{
"ago" => "há {time}",
"from_now" => "em {time}",
"second" => " segundo",
"minute" => " minuto",
"hour" => " hora",
"day" => " dia"
},
"ru" => %{
"ago" => "{time} назад",
"from_now" => "через {time}",
"second" => " секунда",
"minute" => " минута",
"hour" => " час",
"day" => " день"
}
}
@default_locale "en"
@doc """
Converts a duration (in milliseconds or DateTime) into a human-readable string with optional localization.
## Examples
iex> HumanTime.human(754000)
"12 minutes from now"
iex> HumanTime.human(-754000)
"12 minutes ago"
iex> HumanTime.human(DateTime.utc_now() |> DateTime.add(5, :second))
"5 seconds from now"
iex> HumanTime.human(DateTime.utc_now() |> DateTime.add(-5, :second))
"5 seconds ago"
iex> HumanTime.human(-754000, locale: "fr")
"il y a 12 minutes"
"""
@spec human(integer() | DateTime.t(), keyword()) :: String.t()
def human(input, opts \\ []) do
locale = Keyword.get(opts, :locale, @default_locale)
verbose = Keyword.get(opts, :verbose, 1)
translations = Map.get(@translations, locale, @translations[@default_locale])
case input do
ms when is_integer(ms) ->
seconds = div(ms, 1000)
human_diff(seconds, translations, locale, verbose)
%DateTime{} = datetime ->
now = DateTime.utc_now()
seconds = DateTime.diff(datetime, now)
human_diff(seconds, translations, locale, verbose)
end
end
defp human_diff(seconds, translations, locale, verbose) do
# Determine if the time is in the past or future
is_future = seconds >= 0
seconds = abs(seconds)
# Calculate the time difference in various units
years = div(seconds, 31_536_000)
seconds = rem(seconds, 31_536_000)
months = div(seconds, 2_592_000)
seconds = rem(seconds, 2_592_000)
days = div(seconds, 86_400)
seconds = rem(seconds, 86_400)
hours = div(seconds, 3_600)
seconds = rem(seconds, 3_600)
minutes = div(seconds, 60)
seconds = rem(seconds, 60)
time_units = [
{"year", years},
{"month", months},
{"day", days},
{"hour", hours},
{"minute", minutes},
{"second", seconds}
]
time_phrase(time_units, translations, verbose, is_future, locale)
end
defp time_phrase(time_units, translations, verbose, is_future, locale) do
time_str = time_units
|> Enum.filter(fn {_, value} -> value > 0 end)
|> Enum.take(verbose)
|> Enum.map(fn {unit, value} ->
unit_translation = Map.get(translations, unit)
"#{value}#{unit_translation}#{pluralize(value, locale)}"
end)
|> Enum.join(", ")
if time_str == "" do
time_str
else
if is_future do
"#{Map.get(translations, "from_now") |> String.replace("{time}", time_str)}"
else
"#{Map.get(translations, "ago") |> String.replace("{time}", time_str)}"
end
end
end
defp pluralize(1, _locale), do: ""
defp pluralize(_, "zh"), do: "" # No pluralization in Chinese
defp pluralize(count, "ru") do
cond do
rem(count, 10) == 1 and rem(count, 100) != 11 -> "" # Singular
rem(count, 10) in 2..4 and rem(count, 100) not in 12..14 -> "ы" # Plural for 2–4
true -> "" # Default for other cases, e.g., 5–20
end
end
defp pluralize(_, _locale), do: "s" # Default to English pluralization
end