Packages

A micro library to help finding the date with the smallest delta to a given date

Current section

Files

Jump to
nearest_date lib nearest_date.ex
Raw

lib/nearest_date.ex

defmodule NearestDate.Error do
@moduledoc """
Exception that represents an error in datetime terms.
This exception has a `:reason` field that can have one of the following
values:
* `:not_found` - means that no datetime was found which meets the critea defined in the options.
* `{:invalid_date, term}` - means that the given term is not a valid datetime format.
"""
@type t :: %__MODULE__{
reason: {:invalid_format, any} | :not_found
}
defexception [:reason]
def message(%__MODULE__{} = exception) do
case exception.reason do
:not_found ->
"no valid datetime found"
{:invalid_format, term} ->
"invalid date format: #{inspect(term)}"
end
end
end
defmodule NearestDate do
@moduledoc """
`NearestDate` is a micro library to help finding the date with the smallest delta to a given date.
"""
@typep timestamp_or_date :: String.t() | DateTime.t()
@typep list_of_timestamps :: [timestamp_or_date]
@type direction :: :future | :past | :both
@spec find!(list_of_timestamps, timestamp_or_date, Keyword.t()) ::
timestamp_or_date | no_return
def find!(timestamps, target, opts \\ []) do
case find(timestamps, target, opts) do
{:ok, result} ->
result
{:error, exception} ->
raise exception
end
end
@doc """
Returns the datetime from the given list with the smallest delta to the given
target datetime.
## Options
* `:direction` - (:future, :past) constrains the search space in one
direction. Defaults to `nil` which will search both future and past.
## Examples
iex> list_of_dates = ["2019-12-31T01:00:00+02:00", "2020-03-01T02:00:00+02:00"]
iex> NearestDate.find(list_of_dates, "2020-01-01T00:00:00+02:00")
{:ok, "2019-12-31T01:00:00+02:00"}
iex> list_of_dates = ["2020-02-01T01:00:00+02:00", "2020-03-01T02:00:00+02:00"]
iex> NearestDate.find(list_of_dates, "2020-01-01T00:00:00+02:00", direction: :future)
{:ok, "2020-02-01T01:00:00+02:00"}
iex> list_of_dates = ["2019-02-01T01:00:00+02:00", "2018-03-01T02:00:00+02:00"]
iex> NearestDate.find(list_of_dates, "2020-01-01T00:00:00+02:00", direction: :future)
{:error, %NearestDate.Error{reason: :not_found}}
iex> list_of_dates = ["2019-02-01T01:00:00+02:00", "2016-03-01T02:00:00+02:00"]
iex> NearestDate.find(list_of_dates, "2020-01-01T00:00:00+02:00", direction: :past)
{:ok, "2019-02-01T01:00:00+02:00"}
"""
@spec find(list_of_timestamps, timestamp_or_date, Keyword.t()) ::
{:ok, timestamp_or_date} | {:error, NearestDate.Error.t() | Exception.t()}
def find(timestamps, target, opts \\ []) do
direction = Keyword.get(opts, :direction)
try do
t1 = parse!(target)
timestamps
|> map_diffs(t1)
|> filter(direction)
|> select(target)
catch
:throw, reason ->
{:error, %NearestDate.Error{reason: reason}}
else
nil ->
{:error, %NearestDate.Error{reason: :not_found}}
datetime ->
{:ok, datetime}
end
end
defp map_diffs(list, target) do
Enum.map(list, fn dt ->
dt1 =
case parse(dt) do
{:error, :invalid_format} -> ~U[9999-12-31 00:00:00Z]
value -> value
end
delta = DateTime.diff(dt1, target)
{dt, delta}
end)
end
defp filter(list, :future) do
# only allow future dates
Enum.filter(list, fn {_date, diff} -> diff > 0 end)
end
defp filter(list, :past) do
# only allow dates in the past
Enum.filter(list, fn {_date, diff} -> diff < 0 end)
end
defp filter(list, _) do
list
end
defp select(list, target) do
try do
Enum.min_by(list, fn {_date, diff} -> abs(diff) end)
|> elem(0)
|> case do
^target -> nil
other -> other
end
rescue
Enum.EmptyError ->
nil
end
end
defp parse!(dt) do
case parse(dt) do
{:error, :invalid_format} ->
throw({:invalid_format, dt})
dt ->
dt
end
end
defp parse(dt) when is_binary(dt) do
with {:ok, dt, _offset} <- DateTime.from_iso8601(dt) do
dt
end
end
defp parse(%DateTime{} = dt) do
dt
end
defp parse(dt) when is_integer(dt) do
dt
end
defp parse(dt) do
throw({:invalid_format, dt})
end
end