defmodule Cldr.Calendar.Duration do
@moduledoc """
Functions to create and format a difference between
two dates, times or datetimes.
The difference between two dates (or times or datetimes) is
usually defined in terms of days or seconds.
A duration is calculated as the difference in time in calendar
units: years, months, days, hours, minutes, seconds and microseconds.
This is useful to support formatting a string for users in
easy-to-understand terms. For example `11 months, 3 days and 4 minutes`
is a lot easier to understand than `28771440` seconds.
The package [ex_cldr_units](https://hex.pm/packages/ex_cldr_units) can
be optionally configured to provide localized formatting of durations.
If configured, the following providers should be configured in the
appropriate CLDR backend module. For example:
```elixir
defmodule MyApp.Cldr do
use Cldr,
locales: ["en", "ja"],
providers: [Cldr.Calendar, Cldr.Number, Cldr.Unit, Cldr.List]
end
```
"""
@struct_list [year: 0, month: 0, day: 0, hour: 0, minute: 0, second: 0, microsecond: 0]
@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: non_neg_integer()
}
@typedoc "A date, time, naivedatetime or datetime"
@type date_or_time_or_datetime ::
Calendar.date()
| Calendar.time()
| Calendar.datetime()
| Calendar.naive_datetime()
@typedoc "A interval as either Date.Range.t() CalendarInterval.t()"
@type interval :: Date.Range.t() | CalendarInterval.t()
@microseconds_in_second 1_000_000
@microseconds_in_day 86_400_000_000
if Code.ensure_loaded?(Cldr.Unit) do
@doc """
Returns a string formatted representation of
a duration.
Note that time units that are zero are omitted
from the output.
Formatting is
## Arguments
* `duration` is a duration of type `t()` returned
by `Cldr.Calendar.Duration.new/2`
* `options` is a Keyword list of options
## Options
* `:except` is a list of time units to be omitted from
the formatted output. It may be useful to use
`except: [:microsecond]` for example. The default is
`[]`.
* `locale` is any valid locale name returned by `Cldr.known_locale_names/1`
or a `Cldr.LanguageTag` struct returned by `Cldr.Locale.new!/2`
The default is `Cldr.get_locale/0`
* `backend` is any module that includes `use Cldr` and therefore
is a `Cldr` backend module. The default is `Cldr.default_backend/0`
* `:list_options` is a list of options passed to `Cldr.List.to_string/3` to
control the final list output.
Any other options are passed to `Cldr.Number.to_string/3` and
`Cldr.Unit.to_string/3` during the formatting process.
## Note
* Any duration parts that are `0` are not output.
## Example
iex> {:ok, duration} = Cldr.Calendar.Duration.new(~D[2019-01-01], ~D[2019-12-31])
iex> Cldr.Calendar.Duration.to_string(duration)
{:ok, "11 months and 30 days"}
"""
def to_string(%__MODULE__{} = duration, options \\ []) do
{except, options} = Keyword.pop(options, :except, [])
for key <- @keys, value = Map.get(duration, key), value != 0 && key not in except do
Cldr.Unit.new!(key, value)
end
|> Cldr.Unit.to_string(options)
end
else
@doc """
Returns a string formatted representation of
a duration.
Note that time units that are zero are omitted
from the output.
## Localized formatting
If localized formatting of a duration is desired,
add `{:ex_cldr_units, "~> 2.0"}` to your `mix.exs`
and ensure you have configured your providers in
your backend configuration to include: `providers:
[Cldr.Calendar, Cldr.Number, Cldr.Unit, Cldr.List]`
## Arguments
* `duration` is a duration of type `t()` returned
by `Cldr.Calendar.Duration.new/2`
* `options` is a Keyword list of options
## Options
* `:except` is a list of time units to be omitted from
the formatted output. It may be useful to use
`except: [:microsecond]` for example. The default is
`[]`.
## Example
iex> {:ok, duration} = Cldr.Calendar.Duration.new(~D[2019-01-01], ~D[2019-12-31])
iex> Cldr.Calendar.Duration.to_string(duration)
{:ok, "11 months, 30 days"}
"""
def to_string(%__MODULE__{} = duration, options \\ []) do
except = Keyword.get(options, :except, [])
formatted =
for key <- @keys, value = Map.get(duration, key), value != 0 && key not in except do
if value > 1, do: "#{value} #{key}s", else: "#{value} #{key}"
end
|> Enum.join(", ")
{:ok, formatted}
end
end
@doc """
Formats a duration as a string or raises
an exception on error.
## Arguments
* `duration` is a duration of type `t()` returned
by `Cldr.Calendar.Duration.new/2`
* `options` is a Keyword list of options
## Options
See `Cldr.Calendar.Duration.to_string/2`
## Returns
* A formatted string or
* raises an exception
"""
@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, reason}} -> raise exception, reason
end
end
@doc """
Calculates the calendar difference between two dates
returning a `Duration` struct.
The difference calculated is in terms of years, months,
days, hours, minutes, seconds and microseconds.
## Arguments
* `from` is a date, time or datetime representing the
start of the duration.
* `to` is a date, time or datetime representing the
end of the duration
## Notes
* `from` must be before or at the same time
as `to`. In addition, both `from` and `to` must
be in the same calendar
* If `from` and `to` are `datetime`s then
they must both be in the same time zone
## Returns
* A `{:ok, duration}` tuple or a
* `{:error, {exception, reason}}` tuple
## Example
iex> Cldr.Calendar.Duration.new(~D[2019-01-01], ~D[2019-12-31])
{:ok,
%Cldr.Calendar.Duration{
year: 0,
month: 11,
day: 30,
hour: 0,
microsecond: 0,
minute: 0,
second: 0
}}
"""
@spec new(from :: date_or_time_or_datetime(), to :: date_or_time_or_datetime()) ::
{:ok, t()} | {:error, {module(), String.t()}}
def new(unquote(Cldr.Calendar.datetime()) = from, unquote(Cldr.Calendar.datetime()) = to) do
with :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(unquote(Cldr.Calendar.date()) = from, unquote(Cldr.Calendar.date()) = to) do
with {:ok, from} <- cast_date_time(from),
{:ok, to} <- cast_date_time(to) do
new(from, to)
end
end
def new(unquote(Cldr.Calendar.time()) = from, unquote(Cldr.Calendar.time()) = to) do
with {:ok, from} <- cast_date_time(from),
{:ok, to} <- cast_date_time(to) do
time_diff = time_duration(from, to)
{seconds, microseconds} = Cldr.Math.div_mod(time_diff, 1000000)
{minutes, seconds} = Cldr.Math.div_mod(seconds, 60)
{hours, minutes} = Cldr.Math.div_mod(minutes, 60)
{:ok,
struct(__MODULE__, hour: hours, minute: minutes, second: seconds, microsecond: microseconds)}
end
end
@doc """
Calculates the calendar difference in
a `Date.Range` or `CalendarInterval`
returning a `Duration` struct.
The difference calculated is in terms of years, months,
days, hours, minutes, seconds and microseconds.
## Arguments
* `interval` is either ` Date.Range.t()` or a
`CalendarInterval.t()`
## Returns
* A `{:ok, duration}` tuple or a
* `{:error, {exception, reason}}` tuple
## Notes
* `CalendarInterval` is defined by the most wonderful
[calendar_interval](https://hex.pm/packages/calendar_interval)
library.
## Example
iex> Cldr.Calendar.Duration.new(Date.range(~D[2019-01-01], ~D[2019-12-31]))
{:ok,
%Cldr.Calendar.Duration{
year: 0,
month: 11,
day: 30,
hour: 0,
microsecond: 0,
minute: 0,
second: 0
}}
"""
@spec new(interval()) :: {:ok, t()} | {:error, {module(), String.t()}}
if Code.ensure_loaded?(CalendarInterval) do
def new(%CalendarInterval{first: first, last: last, precision: precision})
when precision in [:year, :month, :day] do
first = %{first | hour: 0, minute: 0, second: 0, microsecond: {0, 6}}
last = %{last | hour: 0, minute: 0, second: 0, microsecond: {0, 6}}
new(first, last)
end
def new(%CalendarInterval{first: first, last: last}) do
new(first, last)
end
end
def new(%Date.Range{first: first, last: last}) do
new(first, last)
end
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
def new(%{calendar: _calendar1} = from, %{calendar: _calendar2} = to) do
{:error,
{Cldr.IncompatibleCalendarError,
"The two dates must be in the same calendar. Found #{inspect(from)} and #{inspect(to)}"}}
end
defp cast_date_time(unquote(Cldr.Calendar.datetime()) = datetime) do
_ = calendar
{:ok, datetime}
end
defp cast_date_time(unquote(Cldr.Calendar.naivedatetime()) = naivedatetime) do
_ = calendar
DateTime.from_naive(naivedatetime, "Etc/UTC")
end
defp cast_date_time(unquote(Cldr.Calendar.date()) = date) do
{:ok, dt} = NaiveDateTime.new(date.year, date.month, date.day, 0, 0, 0, {0, 6}, calendar)
DateTime.from_naive(dt, "Etc/UTC")
end
defp cast_date_time(unquote(Cldr.Calendar.time()) = time) do
{:ok, dt} =
NaiveDateTime.new(1, 1, 1, time.hour, time.minute, time.second, time.microsecond, Calendar.ISO)
DateTime.from_naive(dt, "Etc/UTC")
end
defp confirm_date_order(from, to) do
if DateTime.compare(from, to) in [:lt, :eq] do
:ok
else
{:error,
{
Cldr.InvalidDateOrder,
"`from` must be earlier or equal to `to`. " <>
"Found #{inspect(from)} and #{inspect(to)}"
}}
end
end
defp confirm_same_time_zone(%{time_zone: zone}, %{time_zone: zone}) do
:ok
end
defp confirm_same_time_zone(from, to) do
{:error,
{Cldr.IncompatibleTimeZone,
"`from` and `to` must be in the same time zone. " <>
"Found #{inspect(from)} and #{inspect(to)}"}}
end
@doc """
Calculates the calendar difference between two dates
returning a `Duration` struct.
The difference calculated is in terms of years, months,
days, hours, minutes, seconds and microseconds.
## Arguments
* `from` is a date, time or datetime representing the
start of the duration
* `to` is a date, time or datetime representing the
end of the duration
Note that `from` must be before or at the same time
as `to`. In addition, both `from` and `to` must
be in the same calendar.
## Returns
* A `duration` struct or
* raises an exception
## Example
iex> Cldr.Calendar.Duration.new!(~D[2019-01-01], ~D[2019-12-31])
%Cldr.Calendar.Duration{
year: 0,
month: 11,
day: 30,
hour: 0,
microsecond: 0,
minute: 0,
second: 0
}
"""
@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, reason}} -> raise exception, reason
end
end
@doc """
Calculates the calendar difference in
a `Date.Range` or `CalendarInterval`
returning a `Duration` struct.
The difference calculated is in terms of years, months,
days, hours, minutes, seconds and microseconds.
## Arguments
* `interval` is either ` Date.Range.t()` or a
`CalendarInterval.t()`
## Returns
* A `duration` struct or
* raises an exception
## Notes
* `CalendarInterval` is defined by the most wonderful
[calendar_interval](https://hex.pm/packages/calendar_interval)
library.
## Example
iex> Cldr.Calendar.Duration.new!(Date.range(~D[2019-01-01], ~D[2019-12-31]))
%Cldr.Calendar.Duration{
year: 0,
month: 11,
day: 30,
hour: 0,
microsecond: 0,
minute: 0,
second: 0
}
"""
@spec new!(interval()) :: t() | no_return()
def new!(interval) do
case new(interval) do
{:ok, duration} -> duration
{:error, {exception, reason}} -> raise exception, reason
end
end
defp time_duration(unquote(Cldr.Calendar.time()) = from, unquote(Cldr.Calendar.time()) = to) do
Time.diff(to, from, :microsecond)
end
# The two dates are the same so there is no duration
@doc false
def date_duration(
%{year: year, month: month, day: day, calendar: calendar},
%{year: year, month: month, day: day, calendar: calendar}
) do
%__MODULE__{}
end
# Two dates in the same calendar can be used
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
# When we have a negative time duration then
# we need to apply a one day adjustment to
# the date difference
defp back_one_day(date_diff, calendar) do
back_one_day(date_diff, :day, calendar)
end
defp back_one_day(%{day: 0} = date_diff, :day, from) do
months_in_year = Cldr.Calendar.months_in_year(from)
previous_month = Cldr.Math.amod(from.month - 1, months_in_year)
days_in_month = from.calendar.days_in_month(from.year, previous_month)
%{date_diff | day: days_in_month}
|> back_one_day(:month, from)
end
defp back_one_day(%{day: day} = date_diff, :day, _from) do
%{date_diff | day: day - 1}
end
defp back_one_day(%{month: 0} = date_diff, :month, from) do
months_in_year = Cldr.Calendar.months_in_year(from)
%{date_diff | month: months_in_year}
|> back_one_day(:year, from)
end
defp back_one_day(%{month: month} = date_diff, :month, _from) do
%{date_diff | month: month - 1}
end
defp back_one_day(%{year: year} = date_diff, :year, _from) do
%{date_diff | year: year - 1}
end
defp merge(duration, microseconds) do
{seconds, microseconds} = Cldr.Math.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, microseconds)
end
end