defmodule Boxic.FEEL.Duration do @moduledoc """ FEEL duration value. FEEL defines year-month and day-time durations as distinct, generally incomparable kinds. A single elapsed-seconds value cannot represent calendar months, while Elixir's date/time APIs do not provide a value that preserves this FEEL distinction or fractional duration seconds. The explicit `kind` field prevents accidental comparison or property access across the two domains. Year-month values use `months`; day-time values use `seconds`, with `Decimal` retained when fractional precision is present. """ defstruct [:kind, :months, :seconds] @type kind :: :year_month | :day_time @type t :: %__MODULE__{kind: kind(), months: integer(), seconds: integer() | Decimal.t()} @doc """ Parses an ISO 8601 year-month or day-time duration. Boxic.FEEL.Duration.parse_iso8601("P1Y2M") """ @spec parse_iso8601(String.t()) :: {:ok, t()} | {:error, :invalid_duration} def parse_iso8601(value) when is_binary(value) do regex = ~r/^(?-)?P(?:(?\d+)Y)?(?:(?\d+)M)?(?:(?\d+)D)?(?:T(?:(?\d+)H)?(?:(?\d+)M)?(?:(?\d+(?:\.\d*)?)S)?)?$/ case Regex.named_captures(regex, value) do nil -> {:error, :invalid_duration} captures -> if Enum.all?(~w(years months days hours minutes seconds), &(captures[&1] in [nil, ""])) do {:error, :invalid_duration} else years = int(captures["years"]) months = int(captures["months"]) days = int(captures["days"]) hours = int(captures["hours"]) minutes = int(captures["minutes"]) seconds = number(captures["seconds"]) sign = if captures["sign"] == "-", do: -1, else: 1 kind = if captures["years"] not in [nil, ""] or captures["months"] not in [nil, ""], do: :year_month, else: :day_time {:ok, %__MODULE__{ kind: kind, months: sign * (years * 12 + months), seconds: multiply(sign, add_number(days * 86_400 + hours * 3_600 + minutes * 60, seconds)) }} end end end @doc """ Creates a day-time duration from whole days. Boxic.FEEL.Duration.from_days(2) """ @spec from_days(integer()) :: t() def from_days(days), do: %__MODULE__{kind: :day_time, months: 0, seconds: days * 86_400} @doc """ Creates a day-time duration from seconds. Boxic.FEEL.Duration.from_seconds(90) """ @spec from_seconds(integer() | Decimal.t()) :: t() def from_seconds(seconds), do: %__MODULE__{kind: :day_time, months: 0, seconds: seconds} @doc """ Negates a duration. Boxic.FEEL.Duration.negate(duration) """ @spec negate(t()) :: t() def negate(%__MODULE__{kind: kind, months: months, seconds: seconds}) do %__MODULE__{kind: kind, months: -months, seconds: multiply(-1, seconds)} end @doc """ Returns the absolute duration. Boxic.FEEL.Duration.abs(duration) """ @spec abs(t()) :: t() def abs(%__MODULE__{kind: kind, months: months, seconds: seconds}) do %__MODULE__{kind: kind, months: Kernel.abs(months), seconds: abs_number(seconds)} end @doc """ Adds durations of the same kind. Boxic.FEEL.Duration.add(left, right) """ @spec add(t(), t()) :: t() def add(%__MODULE__{kind: kind} = left, %__MODULE__{kind: kind} = right) do %__MODULE__{ kind: kind, months: left.months + right.months, seconds: add_number(left.seconds, right.seconds) } end @doc """ Subtracts `right` from `left`. Boxic.FEEL.Duration.subtract(left, right) """ @spec subtract(t(), t()) :: t() def subtract(%__MODULE__{} = left, %__MODULE__{} = right), do: add(left, negate(right)) @doc """ Multiplies a duration by a decimal factor. Boxic.FEEL.Duration.scale(duration, Decimal.new("1.5")) """ @spec scale(t(), Decimal.t()) :: t() def scale(%__MODULE__{kind: :year_month} = duration, factor) do months = duration.months |> Decimal.new() |> Decimal.mult(factor) |> Decimal.round(0, :down) |> Decimal.to_integer() %{duration | months: months, seconds: 0} end def scale(%__MODULE__{kind: :day_time} = duration, factor) do %{duration | months: 0, seconds: Decimal.mult(decimal(duration.seconds), factor)} end @doc """ Divides two durations of the same kind. Boxic.FEEL.Duration.ratio(left, right) """ @spec ratio(t(), t()) :: {:ok, Decimal.t()} | :error def ratio(%__MODULE__{kind: kind} = left, %__MODULE__{kind: kind} = right) do {left_value, right_value} = if kind == :year_month, do: {Decimal.new(left.months), Decimal.new(right.months)}, else: {decimal(left.seconds), decimal(right.seconds)} if Decimal.equal?(right_value, Decimal.new(0)), do: :error, else: {:ok, Decimal.div(left_value, right_value)} end def ratio(%__MODULE__{}, %__MODULE__{}), do: :error @doc """ Compares durations of the same kind. Boxic.FEEL.Duration.compare(left, right) """ @spec compare(t(), t()) :: :lt | :eq | :gt | :unordered def compare(%__MODULE__{kind: kind} = left, %__MODULE__{kind: kind} = right) do case kind do :year_month -> compare_numbers(left.months, right.months) :day_time -> compare_numbers(left.seconds, right.seconds) end end def compare(%__MODULE__{}, %__MODULE__{}), do: :unordered @doc """ Serializes a duration as ISO 8601 text. Boxic.FEEL.Duration.to_string(duration) """ @spec to_string(t()) :: String.t() def to_string(%__MODULE__{kind: :year_month, months: months}) do sign = if months < 0, do: "-", else: "" months = Kernel.abs(months) years = div(months, 12) remainder = rem(months, 12) body = cond do years > 0 and remainder > 0 -> "#{years}Y#{remainder}M" years > 0 -> "#{years}Y" true -> "#{remainder}M" end sign <> "P" <> body end def to_string(%__MODULE__{kind: :day_time, seconds: seconds}) do seconds = decimal(seconds) sign = if Decimal.negative?(seconds), do: "-", else: "" seconds = Decimal.abs(seconds) whole = seconds |> Decimal.round(0, :down) |> Decimal.to_integer() fraction = Decimal.sub(seconds, Decimal.new(whole)) days = div(whole, 86_400) hours = div(rem(whole, 86_400), 3_600) minutes = div(rem(whole, 3_600), 60) second = Decimal.add(Decimal.new(rem(whole, 60)), fraction) date_part = if days > 0, do: "#{days}D", else: "" time_part = [ if(hours > 0, do: "#{hours}H"), if(minutes > 0, do: "#{minutes}M"), if(Decimal.equal?(second, Decimal.new(0)) and (days > 0 or hours > 0 or minutes > 0), do: nil, else: format_seconds(second) ) ] |> Enum.reject(&is_nil/1) |> Enum.join() time_marker = if time_part == "", do: "", else: "T" sign <> "P" <> date_part <> time_marker <> time_part end @doc """ Adds a duration to an Elixir date. Boxic.FEEL.Duration.add_to_date(~D[2026-01-01], duration) """ @spec add_to_date(Date.t(), t()) :: Date.t() def add_to_date(%Date{} = date, %__MODULE__{} = duration) do day_delta = duration.seconds |> decimal() |> Decimal.div(Decimal.new(86_400)) |> Decimal.round(0, :floor) |> Decimal.to_integer() date |> add_months(duration.months) |> Date.add(day_delta) end @doc """ Adds a duration to an Elixir date-time. Boxic.FEEL.Duration.add_to_datetime(datetime, duration) """ @spec add_to_datetime(DateTime.t(), t()) :: DateTime.t() def add_to_datetime(%DateTime{} = datetime, %__MODULE__{} = duration) do shifted_date = add_months(DateTime.to_date(datetime), duration.months) {:ok, shifted_datetime} = DateTime.new(shifted_date, DateTime.to_time(datetime), datetime.time_zone) DateTime.add(shifted_datetime, truncate(duration.seconds), :second) end @doc """ Adds a day-time duration to an Elixir time. Boxic.FEEL.Duration.add_to_time(~T[12:00:00], duration) """ @spec add_to_time(Time.t(), t()) :: Time.t() def add_to_time(%Time{} = time, %__MODULE__{} = duration) do total_seconds = rem( time.hour * 3_600 + time.minute * 60 + time.second + truncate(duration.seconds), 86_400 ) normalized = if total_seconds < 0, do: total_seconds + 86_400, else: total_seconds hour = div(normalized, 3_600) minute = div(rem(normalized, 3_600), 60) second = rem(normalized, 60) {:ok, result} = Time.new(hour, minute, second) result end defp add_months(%Date{} = date, 0), do: date defp add_months(%Date{} = date, months_delta) do total_month = date.month + months_delta year = date.year + floor_div(total_month - 1, 12) month = Integer.mod(total_month - 1, 12) + 1 day = min(date.day, Date.days_in_month(%Date{year: year, month: month, day: 1})) {:ok, shifted} = Date.new(year, month, day) shifted end defp floor_div(value, divisor) do quotient = div(value, divisor) remainder = rem(value, divisor) if remainder < 0 do quotient - 1 else quotient end end defp int(nil), do: 0 defp int(""), do: 0 defp int(value), do: String.to_integer(value) defp number(nil), do: 0 defp number(""), do: 0 defp number(value) do if String.contains?(value, "."), do: Decimal.new(String.trim_trailing(value, ".")), else: int(value) end defp decimal(%Decimal{} = value), do: value defp decimal(value), do: Decimal.new(value) defp format_seconds(value) do if Decimal.equal?(value, Decimal.new(0)) do "0S" else Decimal.to_string(value, :normal) <> "S" end end defp add_number(%Decimal{} = left, right), do: Decimal.add(left, Decimal.new(right)) defp add_number(left, %Decimal{} = right), do: Decimal.add(Decimal.new(left), right) defp add_number(left, right), do: left + right defp multiply(multiplier, %Decimal{} = value), do: Decimal.mult(Decimal.new(multiplier), value) defp multiply(multiplier, value), do: multiplier * value defp abs_number(%Decimal{} = value), do: Decimal.abs(value) defp abs_number(value), do: Kernel.abs(value) defp truncate(%Decimal{} = value), do: value |> Decimal.round(0, :down) |> Decimal.to_integer() defp truncate(value), do: value defp compare_numbers(%Decimal{} = left, %Decimal{} = right), do: Decimal.compare(left, right) defp compare_numbers(%Decimal{} = left, right), do: Decimal.compare(left, Decimal.new(right)) defp compare_numbers(left, %Decimal{} = right), do: Decimal.compare(Decimal.new(left), right) defp compare_numbers(left, right) when left < right, do: :lt defp compare_numbers(left, right) when left > right, do: :gt defp compare_numbers(_left, _right), do: :eq end