Current section

Files

Jump to
localize lib localize duration.ex
Raw

lib/localize/duration.ex

defmodule Localize.Duration do
@moduledoc """
Functions to create and format durations — the difference
between two dates, times, or datetimes expressed in calendar
units.
A duration is represented as years, months, days, hours,
minutes, seconds, and microseconds. This is useful for
producing human-readable strings like "11 months and 30 days"
or numeric patterns like "37:48:12".
## Creating durations
* `new/2` — calculates the duration between two dates, times,
or datetimes.
* `new_from_seconds/1` — creates a duration from a number of
seconds.
## Formatting durations
* `to_string/2` — formats a duration as a localized string
using unit names (e.g., "11 months and 30 days") via
`Localize.Unit` and `Localize.List`.
* `to_time_string/2` — formats the time portion of a duration
using a pattern like `"hh:mm:ss"`. Hours are unbounded
(e.g., "37:48:12" for 37 hours).
"""
import Kernel, except: [to_string: 1]
@struct_list [year: 0, month: 0, day: 0, hour: 0, minute: 0, second: 0, microsecond: {0, 6}]
@keys Keyword.keys(@struct_list)
defstruct @struct_list
@typedoc "Duration in calendar units."
@type t :: %__MODULE__{
year: non_neg_integer(),
month: non_neg_integer(),
day: non_neg_integer(),
hour: non_neg_integer(),
minute: non_neg_integer(),
second: non_neg_integer(),
microsecond: {integer(), 1..6}
}
@typedoc "A date, time, naive datetime, or datetime."
@type date_or_time_or_datetime ::
Calendar.date()
| Calendar.time()
| Calendar.datetime()
| Calendar.naive_datetime()
@microseconds_in_second 1_000_000
@microseconds_in_day 86_400_000_000
# ── Creating durations ──────────────────────────────────────────
@doc """
Calculates the calendar duration between two dates, times, or
datetimes.
### Arguments
* `from` is a date, time, or datetime representing the start.
* `to` is a date, time, or datetime representing the end.
### Returns
* `{:ok, duration}` where `duration` is a `t:t/0` struct.
* `{:error, exception}` if the arguments are incompatible.
### Examples
iex> {:ok, d} = Localize.Duration.new(~D[2019-01-01], ~D[2019-12-31])
iex> d.month
11
iex> {:ok, d} = Localize.Duration.new(~T[10:00:00], ~T[12:30:45])
iex> {d.hour, d.minute, d.second}
{2, 30, 45}
"""
@spec new(from :: date_or_time_or_datetime(), to :: date_or_time_or_datetime()) ::
{:ok, t()} | {:error, Exception.t() | atom()}
@dialyzer {:nowarn_function, new: 2}
def new(
%{year: _, month: _, day: _, hour: _, minute: _, second: _} = from,
%{year: _, month: _, day: _, hour: _, minute: _, second: _} = to
) do
with :ok <- confirm_same_calendar(from, to),
:ok <- confirm_same_time_zone(from, to),
:ok <- confirm_date_order(from, to) do
time_diff = time_duration(from, to)
date_diff = date_duration(from, to)
apply_time_diff_to_duration(date_diff, time_diff, from)
end
end
def new(
%{year: _, month: _, day: _} = from,
%{year: _, month: _, day: _} = to
) do
with {:ok, from_dt} <- cast_to_datetime(from),
{:ok, to_dt} <- cast_to_datetime(to) do
new(from_dt, to_dt)
end
end
def new(
%{hour: _, minute: _, second: _} = from,
%{hour: _, minute: _, second: _} = to
) do
with {:ok, from_dt} <- cast_to_datetime(from),
{:ok, to_dt} <- cast_to_datetime(to) do
time_diff = time_duration(from_dt, to_dt)
{seconds, microseconds} = div_mod(time_diff, @microseconds_in_second)
{minutes, seconds} = div_mod(seconds, 60)
{hours, minutes} = div_mod(minutes, 60)
{:ok,
struct(__MODULE__,
hour: hours,
minute: minutes,
second: seconds,
microsecond: microsecond_precision(microseconds)
)}
end
end
def new(%Date.Range{first: first, last: last}) do
new(first, last)
end
@doc """
Same as `new/2` but raises on error.
### Examples
iex> d = Localize.Duration.new!(~D[2019-01-01], ~D[2019-12-31])
iex> d.month
11
"""
@spec new!(from :: date_or_time_or_datetime(), to :: date_or_time_or_datetime()) ::
t() | no_return()
def new!(from, to) do
case new(from, to) do
{:ok, duration} -> duration
{:error, exception} -> raise exception
end
end
@doc """
Creates a duration from a number of seconds.
The duration will contain only hours, minutes, seconds,
and microseconds (year/month/day will be zero).
### Arguments
* `seconds` is a number of seconds (integer or float).
### Returns
* A `t:t/0` struct.
### Examples
iex> d = Localize.Duration.new_from_seconds(136_092)
iex> {d.hour, d.minute, d.second}
{37, 48, 12}
iex> d = Localize.Duration.new_from_seconds(90.5)
iex> {d.minute, d.second}
{1, 30}
"""
@spec new_from_seconds(seconds :: number()) :: t()
def new_from_seconds(seconds) when is_number(seconds) do
microseconds = microseconds_from_fraction(seconds)
seconds = trunc(seconds)
hours = div(seconds, 3600)
remainder = rem(seconds, 3600)
minutes = div(remainder, 60)
seconds = rem(remainder, 60)
%__MODULE__{
hour: hours,
minute: minutes,
second: seconds,
microsecond: microseconds
}
end
# ── Formatting ──────────────────────────────────────────────────
@doc """
Formats a duration as a localized string using unit names.
Non-zero duration parts are formatted as units and joined
with the locale's list conjunction (e.g., "11 months and
30 days").
### Arguments
* `duration` is a `t:t/0` struct.
* `options` is a keyword list of options.
### Options
* `:except` is a list of time unit atoms to omit from
the output (e.g., `[:microsecond]`). The default is
`[:microsecond]`.
* `:locale` is a locale identifier. The default is
`Localize.get_locale()`.
* `:format` is one of `:long`, `:short`, or `:narrow`.
The default is `:long`. (`:style` is accepted as a deprecated
alias and will be removed by Localize 1.0 and no later than
December 2026.)
### Returns
* `{:ok, formatted_string}` on success.
* `{:error, exception}` if formatting fails.
### Examples
iex> {:ok, d} = Localize.Duration.new(~D[2019-01-01], ~D[2019-12-31])
iex> Localize.Duration.to_string(d, locale: :en)
{:ok, "11 months and 30 days"}
iex> {:ok, d} = Localize.Duration.new(~D[2019-01-01], ~D[2019-12-31])
iex> Localize.Duration.to_string(d, locale: :en, format: :narrow)
{:ok, "11m and 30d"}
"""
@spec to_string(t(), Keyword.t()) :: {:ok, String.t()} | {:error, Exception.t()}
def to_string(%__MODULE__{} = duration, options \\ []) do
except = Keyword.get(options, :except, [:microsecond])
locale = Keyword.get(options, :locale, Localize.get_locale())
format = Keyword.get(options, :format, Keyword.get(options, :style, :long))
units =
for key <- @keys,
value = Map.get(duration, key),
extract_microseconds(key, value) != 0 && key not in except do
value = extract_microseconds(key, value)
Localize.Unit.new!(value, Atom.to_string(key))
end
case units do
[] ->
# All parts are zero — format as "0 seconds"
unit = Localize.Unit.new!(0, "second")
with {:ok, formatted} <- Localize.Unit.to_string(unit, locale: locale, format: format) do
{:ok, formatted}
end
units ->
with {:ok, formatted_parts} <- format_each(units, locale, format) do
# :standard is the "and"-conjunction list style.
Localize.List.to_string(formatted_parts, locale: locale, list_style: :standard)
end
end
end
# Format each unit, short-circuiting on the first error so a single
# bad locale or unit cannot crash duration formatting with a
# `MatchError`. Returns `{:ok, list}` only when every part formats.
defp format_each(units, locale, format) do
Enum.reduce_while(units, {:ok, []}, fn unit, {:ok, acc} ->
case Localize.Unit.to_string(unit, locale: locale, format: format) do
{:ok, formatted} -> {:cont, {:ok, [formatted | acc]}}
{:error, _} = error -> {:halt, error}
end
end)
|> case do
{:ok, parts} -> {:ok, Enum.reverse(parts)}
{:error, _} = error -> error
end
end
@doc """
Same as `to_string/2` but raises on error.
"""
@spec to_string!(t(), Keyword.t()) :: String.t() | no_return()
def to_string!(%__MODULE__{} = duration, options \\ []) do
case to_string(duration, options) do
{:ok, string} -> string
{:error, exception} -> raise exception
end
end
@doc """
Formats the time portion of a duration using a numeric
pattern like `"hh:mm:ss"`.
Hours are unbounded — a duration of 37 hours, 48 minutes,
and 12 seconds formats as `"37:48:12"`.
### Arguments
* `duration` is a `t:t/0` struct.
* `options` is a keyword list of options.
### Options
* `:format` is a format pattern string. The default is
`"hh:mm:ss"`. Use `"h:mm:ss"` for no zero-padding on
hours, or `"mm:ss"` for minutes and seconds only.
### Returns
* `{:ok, formatted_string}` on success.
### Examples
iex> d = Localize.Duration.new_from_seconds(136_092)
iex> Localize.Duration.to_time_string(d)
{:ok, "37:48:12"}
iex> d = Localize.Duration.new_from_seconds(65)
iex> Localize.Duration.to_time_string(d, format: "m:ss")
{:ok, "1:05"}
"""
@spec to_time_string(t(), Keyword.t()) :: {:ok, String.t()}
def to_time_string(%__MODULE__{} = duration, options \\ []) do
format = Keyword.get(options, :format, "hh:mm:ss")
{:ok, format_time_pattern(duration, format)}
end
@doc """
Same as `to_time_string/2` but raises on error.
"""
@spec to_time_string!(t(), Keyword.t()) :: String.t()
def to_time_string!(%__MODULE__{} = duration, options \\ []) do
case to_time_string(duration, options) do
{:ok, string} -> string
end
end
# ── Time pattern formatting ─────────────────────────────────────
defp format_time_pattern(duration, format) do
format
|> String.graphemes()
|> chunk_pattern()
|> Enum.map(fn
{:field, "hh"} -> pad(duration.hour, 2)
{:field, "h"} -> Integer.to_string(duration.hour)
{:field, "mm"} -> pad(duration.minute, 2)
{:field, "m"} -> Integer.to_string(duration.minute)
{:field, "ss"} -> pad(duration.second, 2)
{:field, "s"} -> Integer.to_string(duration.second)
{:literal, text} -> text
end)
|> IO.iodata_to_binary()
end
defp chunk_pattern(graphemes) do
graphemes
|> Enum.chunk_by(fn g -> g in ~w(h m s) end)
|> Enum.map(fn
[c | _] = chars when c in ~w(h m s) -> {:field, Enum.join(chars)}
chars -> {:literal, Enum.join(chars)}
end)
end
defp pad(number, width) do
number
|> Integer.to_string()
|> String.pad_leading(width, "0")
end
# ── Private: duration calculation ───────────────────────────────
defp apply_time_diff_to_duration(date_diff, time_diff, from) do
duration =
if time_diff < 0 do
back_one_day(date_diff, from)
|> merge(@microseconds_in_day + time_diff)
else
date_diff |> merge(time_diff)
end
{:ok, duration}
end
defp time_duration(from, to) do
Time.diff(to, from, :microsecond)
end
@doc false
def date_duration(
%{year: year, month: month, day: day, calendar: calendar},
%{year: year, month: month, day: day, calendar: calendar}
) do
%__MODULE__{}
end
def date_duration(%{calendar: calendar} = from, %{calendar: calendar} = to) do
increment =
if from.day > to.day do
calendar.days_in_month(from.year, from.month)
else
0
end
{day_diff, increment} =
if increment != 0 do
{increment + to.day - from.day, 1}
else
{to.day - from.day, 0}
end
{month_diff, increment} =
if from.month + increment > to.month do
{to.month + calendar.months_in_year(to.year) - from.month - increment, 1}
else
{to.month - from.month - increment, 0}
end
year_diff = to.year - from.year - increment
%__MODULE__{year: year_diff, month: month_diff, day: day_diff}
end
defp merge(duration, microseconds) do
{seconds, microseconds} = div_mod(microseconds, @microseconds_in_second)
{hours, minutes, seconds} = :calendar.seconds_to_time(seconds)
duration
|> Map.put(:hour, hours)
|> Map.put(:minute, minutes)
|> Map.put(:second, seconds)
|> Map.put(:microsecond, microsecond_precision(microseconds))
end
defp back_one_day(%{day: 0} = date_diff, from) do
previous_month = Integer.mod(from.month - 2, from.calendar.months_in_year(from.year)) + 1
days_in_month = from.calendar.days_in_month(from.year, previous_month)
back_one_month(%{date_diff | day: days_in_month})
end
defp back_one_day(%{day: day} = date_diff, _from) do
%{date_diff | day: day - 1}
end
defp back_one_month(%{month: 0} = date_diff) do
%{date_diff | month: 11, year: date_diff.year - 1}
end
defp back_one_month(%{month: month} = date_diff) do
%{date_diff | month: month - 1}
end
# ── Private: type casting ───────────────────────────────────────
defp cast_to_datetime(%{__struct__: _, year: _, month: _, day: _, hour: _} = dt) do
{:ok, dt}
end
defp cast_to_datetime(%{__struct__: _, year: y, month: m, day: d, calendar: calendar}) do
{:ok, naive} = NaiveDateTime.new(y, m, d, 0, 0, 0, {0, 6}, calendar)
DateTime.from_naive(naive, "Etc/UTC")
end
defp cast_to_datetime(%{__struct__: _, year: y, month: m, day: d}) do
{:ok, naive} = NaiveDateTime.new(y, m, d, 0, 0, 0, {0, 6})
DateTime.from_naive(naive, "Etc/UTC")
end
defp cast_to_datetime(%{__struct__: _, hour: h, minute: m, second: s, microsecond: us}) do
{:ok, naive} = NaiveDateTime.new(1, 1, 1, h, m, s, us)
DateTime.from_naive(naive, "Etc/UTC")
end
defp cast_to_datetime(%{__struct__: _, hour: h, minute: m, second: s}) do
{:ok, naive} = NaiveDateTime.new(1, 1, 1, h, m, s, {0, 6})
DateTime.from_naive(naive, "Etc/UTC")
end
# ── Private: validation ─────────────────────────────────────────
defp confirm_same_calendar(%{calendar: c}, %{calendar: c}), do: :ok
defp confirm_same_calendar(from, to) do
{:error,
ArgumentError.exception(
"The two values must use the same calendar. " <>
"Found #{inspect(from)} and #{inspect(to)}"
)}
end
defp confirm_same_time_zone(%{time_zone: zone}, %{time_zone: zone}), do: :ok
defp confirm_same_time_zone(%{time_zone: _}, %{time_zone: _} = _to), do: :ok
defp confirm_same_time_zone(_, _), do: :ok
defp confirm_date_order(from, to) do
if DateTime.compare(from, to) in [:lt, :eq] do
:ok
else
{:error,
ArgumentError.exception(
"`from` must be earlier or equal to `to`. " <>
"Found #{inspect(from)} and #{inspect(to)}"
)}
end
end
# ── Private: microsecond handling ───────────────────────────────
defp extract_microseconds(:microsecond, {microseconds, _precision}), do: microseconds
defp extract_microseconds(_key, value), do: value
defp microseconds_from_fraction(number) when is_integer(number), do: {0, 6}
defp microseconds_from_fraction(number) when is_float(number) do
fraction = number - trunc(number)
if fraction == 0.0 do
{0, 6}
else
microseconds = round(fraction * @microseconds_in_second)
microsecond_precision(microseconds)
end
end
defp microsecond_precision(0), do: {0, 6}
defp microsecond_precision(us) when us < 10, do: {us, 1}
defp microsecond_precision(us) when us < 100, do: {us, 2}
defp microsecond_precision(us) when us < 1_000, do: {us, 3}
defp microsecond_precision(us) when us < 10_000, do: {us, 4}
defp microsecond_precision(us) when us < 100_000, do: {us, 5}
defp microsecond_precision(us), do: {us, 6}
defp div_mod(a, b), do: {div(a, b), rem(a, b)}
end