Packages
moar
4.2.0
5.0.0
4.3.0
4.2.0
4.1.0
4.0.0
3.2.0
3.1.0
3.0.0
2.7.0
2.6.0
2.5.0
2.4.2
2.4.1
2.4.0
2.3.0
2.2.0
2.1.0
2.0.1
2.0.0
1.64.0
1.63.1
1.63.0
1.62.0
1.61.2
1.61.1
1.61.0
1.60.0
1.59.2
1.59.1
1.59.0
1.58.0
1.57.0
1.56.2
1.56.1
1.56.0
1.55.0
1.54.0
1.53.0
1.52.1
1.52.0
1.51.0
1.50.0
1.49.0
1.48.0
1.47.0
1.46.0
1.45.1
1.45.0
1.44.0
1.43.0
1.42.0
1.41.0
1.40.0
1.39.0
1.38.0
1.37.0
1.36.0
1.35.0
1.34.0
1.33.0
1.32.0
1.31.0
1.30.0
1.29.0
1.28.0
1.27.0
1.26.0
1.25.0
1.24.1
1.24.0
1.23.0
1.22.0
1.21.0
1.20.0
1.19.3
1.19.2
1.19.1
1.19.0
1.18.1
1.18.0
1.17.0
1.16.0
1.15.0
1.14.0
1.13.1
1.13.0
1.12.0
1.11.0
1.10.0
1.9.0
1.8.0
1.7.0
1.6.0
1.5.0
1.4.0
1.3.0
1.2.0
1.1.0
1.0.0
0.1.0
A dependency-free utility library containing 100+ useful functions.
Current section
Files
Jump to
Current section
Files
lib/duration.ex
defmodule Moar.Duration do
# @related [test](/test/duration_test.exs)
@moduledoc """
A duration is a `{time, unit}` tuple.
The time is a number and the unit is one of:
* `:nanosecond`
* `:microsecond`
* `:millisecond`
* `:second`
* `:minute`
* `:hour`
* `:day`
* `:approx_month` (30 days)
* `:approx_year` (360 days)
> #### Note {: .info}
>
> This module is naive and intentionally doesn't account for real-world calendars and all of their complexity,
> such as leap years, leap days, daylight saving time, past and future calendar oddities, etc.
>
> As ["Falsehoods programmers believe about time"](https://gist.github.com/timvisee/fcda9bbdff88d45cc9061606b4b923ca)
> says, "If you think you understand everything about time, you're probably doing it wrong."
>
> See [`Cldr.Calendar.Duration`](https://hexdocs.pm/ex_cldr_calendars/Cldr.Calendar.Duration.html) for one example
> of a full-featured library that is far more likely to be correct.
"""
defmodule UnitRecord do
@moduledoc false
require Record
Record.defrecord(:unit, name: nil, long: nil, short: nil, conversion: nil)
end
defmodule Unit do
@moduledoc false
import UnitRecord
@approx_year unit(name: :approx_year, long: "year", short: "yr", conversion: {12, :approx_month})
@approx_month unit(name: :approx_month, long: "month", short: "mo", conversion: {30, :day})
@day unit(name: :day, long: "day", short: "d", conversion: {24, :hour})
@hour unit(name: :hour, long: "hour", short: "h", conversion: {60, :minute})
@minute unit(name: :minute, long: "minute", short: "m", conversion: {60, :second})
@second unit(name: :second, long: "second", short: "s", conversion: {1000, :millisecond})
@millisecond unit(name: :millisecond, long: "millisecond", short: "ms", conversion: {1000, :microsecond})
@microsecond unit(name: :microsecond, long: "microsecond", short: "us", conversion: {1000, :nanosecond})
@nanosecond unit(name: :nanosecond, long: "nanosecond", short: "ns", conversion: nil)
@units_desc [@approx_year, @approx_month, @day, @hour, @minute, @second, @millisecond, @microsecond, @nanosecond]
@unit_names_desc Enum.map(@units_desc, fn record -> unit(record, :name) end)
@units_asc Enum.reverse(@units_desc)
@unit_names_asc Enum.map(@units_asc, fn record -> unit(record, :name) end)
@unit_map Map.new(@units_desc, fn record -> {unit(record, :name), record} end)
# # #
def conversion(unit_name) when is_atom(unit_name), do: @unit_map[unit_name] |> conversion()
def conversion(unit_record), do: unit(unit_record, :conversion)
def long_name(unit_name) when is_atom(unit_name), do: @unit_map[unit_name] |> long_name()
def long_name(unit_record), do: unit(unit_record, :long)
def multiplier(unit_name) when is_atom(unit_name), do: @unit_map[unit_name] |> multiplier()
def multiplier(unit_record), do: conversion(unit_record) |> elem(0)
def name(unit_name) when is_atom(unit_name), do: unit_name
def name(unit_record), do: unit(unit_record, :name)
def names(:asc), do: @unit_names_asc
def names(:desc), do: @unit_names_desc
def short_name(unit_name) when is_atom(unit_name), do: @unit_map[unit_name] |> short_name()
def short_name(unit_record), do: unit(unit_record, :short)
# # #
def larger(unit_name) when unit_name in @unit_names_desc do
case Enum.find_index(@unit_names_desc, &(&1 == unit_name)) do
0 -> nil
index -> Enum.at(@units_desc, index - 1)
end
end
def larger?(unit_a, unit_b) do
Enum.find_index(@unit_names_desc, &(&1 == unit_a)) < Enum.find_index(@unit_names_desc, &(&1 == unit_b))
end
end
# # #
@type date_time_ish() :: DateTime.t() | NaiveDateTime.t() | binary()
@type format_style() :: :long | :short
@type format_transformer() :: :ago | :approx | :humanize
@type format_transformers() :: format_transformer() | [format_transformer()]
@type t() :: {time :: number(), unit :: time_unit()}
@type time_unit() ::
:nanosecond
| :microsecond
| :millisecond
| :second
| :minute
| :hour
| :day
| :approx_month
| :approx_year
@doc """
Returns the duration between `datetime` and now, in the largest possible unit.
`datetime` can be an ISO8601-formatted string, a `DateTime`, or a `NaiveDateTime`.
See also `from_now/1`.
```elixir
iex> DateTime.utc_now()
...> |> Moar.DateTime.subtract({121, :minute})
...> |> Moar.Duration.ago()
...> |> Moar.Duration.shift(:minute)
{121, :minute}
```
"""
@spec ago(date_time_ish()) :: t()
def ago(datetime) when is_binary(datetime), do: datetime |> Moar.DateTime.from_iso8601!() |> ago()
def ago(%module{} = datetime), do: between(datetime, module.utc_now())
@doc """
Shifts `duration` to an approximately equal duration that's simpler. For example, `{121, :second}` would get
shifted to `{2, :minute}`.
> #### Warning {: .warning}
>
> This function is lossy because it intentionally loses precision.
If the time value of the duration is exactly 1, the duration is returned unchanged: `{1, :minute}` => `{1, :minute}`.
Otherwise, the duration is shifted to the highest unit where the time value is >= 2.
```elixir
iex> Moar.Duration.approx({1, :minute})
{1, :minute}
iex> Moar.Duration.approx({7300, :second})
{2, :hour}
```
"""
@spec approx(t()) :: t()
def approx({1, _unit} = duration), do: duration
def approx(duration), do: approx(duration, Unit.names(:desc))
defp approx(duration, [head_unit | tail_units] = _units_desc) do
new_duration = {new_time, _new_unit} = shift(duration, head_unit)
if new_time >= 2 || tail_units == [],
do: new_duration,
else: approx(duration, tail_units)
end
@doc """
Returns the duration between `earlier` and `later`, in the largest possible unit.
`earlier` and `later` can be ISO8601-formatted strings, `DateTime`s, or `NaiveDateTime`s.
```elixir
iex> earlier = ~U[2020-01-01T00:00:00.000000Z]
iex> later = ~U[2020-01-01T02:01:00.000000Z]
iex> Moar.Duration.between(earlier, later)
{121, :minute}
```
"""
@spec between(date_time_ish(), date_time_ish()) :: t()
def between(earlier, later), do: {Moar.Difference.diff(later, earlier), :microsecond} |> humanize()
@doc """
Converts a `{duration, time_unit}` tuple into a numeric duration, rounding down to the nearest whole number.
> #### Warning {: .warning}
>
> This function is lossy because it rounds down to the nearest whole number.
Uses `System.convert_time_unit/3` under the hood; see its documentation for more details.
It is similar to `shift/1` but this function returns an integer value, while `shift/1` returns a duration tuple.
```elixir
iex> Moar.Duration.convert({121, :second}, :minute)
2
```
"""
@spec convert(from :: t(), to :: time_unit()) :: number()
def convert(from_duration, to_unit), do: shift(from_duration, to_unit) |> elem(0)
@doc """
Formats a duration in either a long or short style, with optional transformers and an optional suffix.
* The first argument is a duration tuple, unless one of the transformers is `:ago`, in which case
it can be a `DateTime`, `NaiveDateTime`, or an ISO8601-formatted string.
* The second argument is optional and is the style, transformer, list of transformers, or suffix.
* The third argument is optional and is the transformer, list of transformers, or suffix.
* The fourth argument is optional and is the suffix.
Styles:
* `:long`, which formats like `"25 seconds"`.
* `:short`, which formats like `"25s"`.
* Defaults to `:long`
Transformers:
* `:ago` transforms via `ago/1`
* `:approx` transforms via `approx/1`
* `:humanize` transforms via `humanize/1`
* If no transformers are specified, no transformations are applied.
Suffix:
* A string that will be appended to the formatted result.
* If the `:ago` transformer is specified and a suffix is not specified, the suffix will default to `"ago"`.
To use the "ago" transformer with no suffix, specify an empty string as the suffix (`nil` will not suffice).
```elixir
iex> Moar.Duration.format({1, :second})
"1 second"
iex> Moar.Duration.format({120, :second})
"120 seconds"
iex> Moar.Duration.format({120, :second}, :long)
"120 seconds"
iex> Moar.Duration.format({120, :second}, :short)
"120s"
iex> Moar.Duration.format({120, :second}, "yonder")
"120 seconds yonder"
iex> Moar.Duration.format({120, :second}, :humanize)
"2 minutes"
iex> Moar.Duration.format({120, :second}, :humanize, "yonder")
"2 minutes yonder"
iex> Moar.Duration.format({310, :second})
"310 seconds"
iex> Moar.Duration.format({310, :second}, :approx)
"5 minutes"
iex> DateTime.utc_now()
...> |> Moar.DateTime.add({-310, :second})
...> |> Moar.Duration.format(:short, [:ago, :approx], "henceforth")
"5m henceforth"
```
"""
@format_styles [:long, :short]
@spec format(
t() | date_time_ish(),
format_style() | format_transformers() | binary() | nil,
format_transformers() | binary() | nil,
binary() | nil
) :: binary()
def format(duration_or_datetime, style_or_transformers_or_suffix \\ nil, transformers_or_suffix \\ nil, suffix \\ nil) do
{style, transformers, suffix} =
[style_or_transformers_or_suffix, transformers_or_suffix, suffix]
|> Enum.reduce({nil, nil, nil}, fn
style, acc when style in @format_styles ->
put_elem(acc, 0, style)
transformers, acc when is_list(transformers) or (is_atom(transformers) and not is_nil(transformers)) ->
transformers = List.wrap(transformers) |> Enum.sort(fn a, _b -> a in [:ago, :from_now] end)
acc = put_elem(acc, 1, transformers)
cond do
:ago in transformers -> put_elem(acc, 2, [" ", "ago"])
:from_now in transformers -> put_elem(acc, 2, [" ", "from now"])
true -> acc
end
"" = _suffix, acc ->
put_elem(acc, 2, "")
suffix, acc when is_binary(suffix) ->
put_elem(acc, 2, [" ", suffix])
nil, acc ->
acc
end)
{time, unit} =
transformers
|> List.wrap()
|> Enum.reduce(duration_or_datetime, fn
:approx, acc -> approx(acc)
:ago, {_time, _unit} = duration -> duration
:ago, acc -> ago(acc)
:from_now, {_time, _unit} = duration -> duration
:from_now, acc -> from_now(acc)
:humanize, acc -> humanize(acc)
other, _acc -> raise "Unknown transformation: #{other}"
end)
unit_name =
if style == :short,
do: Unit.short_name(unit),
else: [" ", Moar.String.pluralize(time, Unit.long_name(unit), &(&1 <> "s"))]
[Kernel.to_string(time), unit_name, suffix] |> Moar.Enum.compact() |> Kernel.to_string()
end
@doc """
Returns the duration between now and `datetime`, in the largest possible unit.
`datetime` can be an ISO8601-formatted string, a `DateTime`, or a `NaiveDateTime`.
See also `ago/1`.
```elixir
iex> DateTime.utc_now()
...> |> Moar.DateTime.add({121, :minute})
...> |> Moar.Duration.from_now()
...> |> Moar.Duration.approx()
{2, :hour}
```
"""
@spec from_now(date_time_ish()) :: t()
def from_now(datetime) when is_binary(datetime), do: datetime |> Moar.DateTime.from_iso8601!() |> from_now()
def from_now(%module{} = datetime), do: between(module.utc_now(), datetime)
@doc """
If possible, shifts `duration` to a higher time unit that is more readable to a human. Returns `duration`
unchanged if it cannot be exactly shifted.
```elixir
iex> Moar.Duration.humanize({60000, :millisecond})
{1, :minute}
iex> Moar.Duration.humanize({48, :hour})
{2, :day}
iex> Moar.Duration.humanize({49, :hour})
{49, :hour}
```
"""
@spec humanize(t()) :: t()
def humanize(duration), do: humanize(duration, Unit.names(:asc))
defp humanize({_time, current_unit} = duration, [head_unit | remaining_units] = _units) do
cond do
Enum.empty?(remaining_units) ->
duration
current_unit == head_unit ->
[next_unit | _] = remaining_units
up = shift(duration, next_unit)
down = shift(up, current_unit)
if down == duration,
do: humanize(up, remaining_units),
else: humanize(duration, remaining_units)
true ->
humanize(duration, remaining_units)
end
end
@doc """
Shifts `duration` to `time_unit`. It is similar to `convert/1` but this function returns a duration tuple,
while `convert/1` just returns an integer value.
> #### Warning {: .warning}
>
> This function is lossy because it rounds down to the nearest whole number.
```elixir
iex> Moar.Duration.shift({121, :second}, :minute)
{2, :minute}
```
"""
@spec shift(t(), time_unit()) :: t()
def shift({_time, from_unit} = duration, to_unit) when from_unit == to_unit,
do: duration
def shift({_time, from_unit} = duration, to_unit) do
if Unit.larger?(from_unit, to_unit),
do: shift_down(duration) |> shift(to_unit),
else: shift_up(duration) |> shift(to_unit)
end
@doc """
Shifts `duration` to the next smaller unit. Raises if it's already at the smallest unit (nanosecond).
```elixir
iex> Moar.Duration.shift_down({1, :hour})
{60, :minute}
```
"""
@spec shift_down(t()) :: t()
def shift_down({time, from_unit} = duration) do
case Unit.conversion(from_unit) do
nil ->
raise "Cannot shift #{inspect(duration)} to a smaller unit because #{from_unit} is the smallest supported unit."
{conversion_multiplier, conversion_unit} ->
{time * conversion_multiplier, conversion_unit}
end
end
@doc """
Shifts `duration` to the next larger unit. Raises if it's already at the largest unit (approx_year).
Rounds the result towards zero.
> #### Warning {: .warning}
>
> This function is lossy because it rounds down to the nearest whole number.
```elixir
iex> Moar.Duration.shift_up({60, :minute})
{1, :hour}
iex> Moar.Duration.shift_up({125, :minute})
{2, :hour}
```
"""
def shift_up({time, from_unit} = duration) do
case Unit.larger(from_unit) do
nil ->
raise "Cannot shift #{inspect(duration)} to a larger unit because #{from_unit} is the largest supported unit."
larger_unit ->
{div(time, Unit.multiplier(larger_unit)), Unit.name(larger_unit)}
end
end
@doc """
Shortcut to `format(duration, :long)`. See `format/4`.
"""
@spec to_string(t()) :: String.t()
def to_string(duration), do: format(duration, :long)
end