Packages

A native Elixir parser and evaluator for the Friendly Enough Expression Language defined by DMN.

Current section

Files

Jump to
boxic_feel lib boxic feel date_time.ex
Raw

lib/boxic/feel/date_time.ex

defmodule Boxic.FEEL.DateTime do
@moduledoc """
Lossless FEEL date-time composed of an Elixir `Date` and a FEEL time.
We retain Elixir's `Date` because its proleptic ISO calendar supports FEEL's
signed year range through ±999,999,999. We do not use Elixir `DateTime` or
`NaiveDateTime` for the complete value because they inherit microsecond time
precision, and `DateTime` depends on a timezone database while normalizing
values around an instant.
FEEL also needs the original temporal form: floating versus offset-based
versus IANA-zoned values can affect equality, ordering, properties, and
serialization. Composing `Date` with `Boxic.FEEL.Time` preserves that
information and allows instant normalization to remain an explicit semantic
operation instead of losing source precision or zone identity at parse time.
"""
alias Boxic.FEEL.Time, as: FeelTime
alias Boxic.FEEL.Duration
defstruct [:date, :time]
@type t :: %__MODULE__{date: Date.t(), time: FeelTime.t()}
@doc """
Combines a date and FEEL time without discarding zone identity.
Boxic.FEEL.DateTime.new(~D[2026-01-01], time)
"""
@spec new(Date.t(), FeelTime.t()) :: t()
def new(%Date{} = date, %FeelTime{} = time), do: %__MODULE__{date: date, time: time}
@doc """
Parses a FEEL date-time.
Boxic.FEEL.DateTime.parse("2026-01-01T12:30:00Z")
"""
@spec parse(String.t()) :: {:ok, t()} | :error
def parse(value) when is_binary(value) do
case String.split(value, "T", parts: 2) do
[date_part, time_part] ->
with {:ok, date} <- parse_date(date_part),
{:ok, date, time_part} <- normalize_end_of_day(date, time_part),
{:ok, time} <- FeelTime.parse(time_part) do
{:ok, new(date, time)}
end
_ ->
:error
end
end
defp normalize_end_of_day(date, "24:00:00" <> zone),
do: {:ok, Date.add(date, 1), "00:00:00" <> zone}
defp normalize_end_of_day(_date, "24:" <> _rest), do: :error
defp normalize_end_of_day(date, time), do: {:ok, date, time}
@doc """
Serializes a FEEL date-time.
Boxic.FEEL.DateTime.to_string(value)
"""
@spec to_string(t()) :: String.t()
def to_string(%__MODULE__{date: date, time: time}) do
Date.to_iso8601(date) <> "T" <> FeelTime.to_string(time)
end
@doc """
Compares compatible FEEL date-times.
Boxic.FEEL.DateTime.compare(left, right)
"""
@spec compare(t(), t()) :: :lt | :eq | :gt | :unordered
def compare(%__MODULE__{} = left, %__MODULE__{} = right) do
case {instant_seconds(left), instant_seconds(right)} do
{{:ok, left_seconds}, {:ok, right_seconds}} -> Decimal.compare(left_seconds, right_seconds)
_ when left.time.zone == right.time.zone -> compare_local(left, right)
_ -> :unordered
end
end
@doc """
Adds a year-month or day-time duration.
Boxic.FEEL.DateTime.add_duration(value, duration)
"""
@spec add_duration(t(), Duration.t()) :: {:ok, t()} | :error
def add_duration(%__MODULE__{} = value, %Duration{kind: :year_month} = duration) do
{:ok, %{value | date: Duration.add_to_date(value.date, duration)}}
end
def add_duration(
%__MODULE__{date: %{year: year}, time: %FeelTime{zone: {:iana, zone}}} = value,
%Duration{
kind: :day_time,
seconds: seconds
}
)
when year > 0 do
with {:ok, datetime} <- to_elixir_datetime(value, zone),
{whole_seconds, fraction} <- split_seconds(seconds),
shifted <-
DateTime.add(datetime, whole_seconds, :second, Tz.TimeZoneDatabase),
{:ok, result} <- from_elixir_datetime(shifted, fraction) do
{:ok, result}
else
_ -> :error
end
end
def add_duration(%__MODULE__{} = value, %Duration{kind: :day_time, seconds: seconds}) do
total = Decimal.add(FeelTime.local_seconds(value.time), decimal(seconds))
day = Decimal.new(86_400)
day_delta = total |> Decimal.div(day) |> Decimal.round(0, :floor) |> Decimal.to_integer()
{:ok,
%{
value
| date: Date.add(value.date, day_delta),
time: FeelTime.add_seconds(value.time, seconds)
}}
end
@doc """
Calculates `left - right` when both values share a comparable timeline.
Boxic.FEEL.DateTime.difference(left, right)
"""
@spec difference(t(), t()) :: {:ok, Duration.t()} | :error
def difference(%__MODULE__{} = left, %__MODULE__{} = right) do
with {:ok, left_seconds} <- instant_seconds(left),
{:ok, right_seconds} <- instant_seconds(right) do
{:ok, Duration.from_seconds(Decimal.sub(left_seconds, right_seconds))}
else
_ when left.time.zone == right.time.zone ->
{:ok, Duration.from_seconds(Decimal.sub(local_seconds(left), local_seconds(right)))}
_ ->
:error
end
end
defp local_seconds(%__MODULE__{date: date, time: time}) do
date
|> Date.to_gregorian_days()
|> Kernel.*(86_400)
|> Decimal.new()
|> Decimal.add(FeelTime.local_seconds(time))
end
@doc """
Converts a zoned FEEL date-time to seconds on a common timeline.
Boxic.FEEL.DateTime.instant_seconds(value)
"""
@spec instant_seconds(t()) :: {:ok, Decimal.t()} | :error
def instant_seconds(%__MODULE__{date: date, time: %FeelTime{zone: {:offset, offset}} = time}) do
seconds =
date
|> Date.to_gregorian_days()
|> Kernel.*(86_400)
|> Decimal.new()
|> Decimal.add(FeelTime.local_seconds(time))
|> Decimal.sub(Decimal.new(offset))
{:ok, seconds}
end
def instant_seconds(%__MODULE__{date: date, time: %FeelTime{zone: {:iana, zone}} = time}) do
case zone_offset(zone, date, time) do
{:ok, offset} ->
instant_seconds(%__MODULE__{date: date, time: %{time | zone: {:offset, offset}}})
:error ->
:error
end
end
def instant_seconds(_value), do: :error
defp compare_local(left, right) do
case Date.compare(left.date, right.date) do
:eq -> FeelTime.compare(left.time, right.time)
result -> result
end
end
defp zone_offset(zone, date, time) do
second = time.second |> Decimal.round(0, :down) |> Decimal.to_integer()
with {:ok, elixir_time} <- Elixir.Time.new(time.hour, time.minute, second),
{:ok, datetime} <- DateTime.new(date, elixir_time, zone, Tz.TimeZoneDatabase) do
{:ok, datetime.utc_offset + datetime.std_offset}
else
_ -> :error
end
end
defp to_elixir_datetime(%__MODULE__{date: date, time: time}, zone) do
second = time.second |> Decimal.round(0, :down) |> Decimal.to_integer()
with {:ok, elixir_time} <- Elixir.Time.new(time.hour, time.minute, second) do
case DateTime.new(date, elixir_time, zone, Tz.TimeZoneDatabase) do
{:ok, datetime} -> {:ok, datetime}
{:ambiguous, first, _second} -> {:ok, first}
_ -> :error
end
end
end
defp from_elixir_datetime(%DateTime{} = value, fraction) do
second = Decimal.add(Decimal.new(value.second), fraction)
with {:ok, time} <-
FeelTime.new(value.hour, value.minute, second, {:iana, value.time_zone}) do
{:ok, new(DateTime.to_date(value), time)}
end
end
defp split_seconds(seconds) do
seconds = decimal(seconds)
whole = seconds |> Decimal.round(0, :down) |> Decimal.to_integer()
{whole, Decimal.sub(seconds, Decimal.new(whole))}
end
defp decimal(%Decimal{} = value), do: value
defp decimal(value), do: Decimal.new(value)
defp parse_date(value) do
case Regex.run(~r/^(-?(?:\d{4}|[1-9]\d{4,8}))-(\d{2})-(\d{2})$/, value,
capture: :all_but_first
) do
[year, month, day] ->
Date.new(String.to_integer(year), String.to_integer(month), String.to_integer(day))
_ ->
:error
end
end
end