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