Current section
Files
Jump to
Current section
Files
lib/plox/date_time_scale.ex
defmodule Plox.DateTimeScale do
@moduledoc """
A scale made of elixir `DateTime` or `NaiveDateTime` values
This struct implements the `Plox.Scale` protocol.
"""
require Logger
defstruct [:first, :last]
@type t :: %__MODULE__{}
@type datetime :: DateTime.t() | NaiveDateTime.t()
@doc """
Creates a new `Plox.DateTimeScale` struct
Accepts 2 elixir `DateTime` or `NaiveDateTime` structs as `first` and `last`.
Negative ranges are not currently supported.
"""
@spec new(first :: datetime(), last :: datetime()) :: t()
def new(first, last)
def new(%date_time_module{} = first, %date_time_module{} = last) when date_time_module in [DateTime, NaiveDateTime] do
if date_time_module.diff(last, first) <= 0 do
raise ArgumentError,
message: "Invalid DateTimeScale: The range must be at least 1 second long and `first` must come before `last`."
end
%__MODULE__{first: first, last: last}
end
def new(_first, _last) do
raise ArgumentError,
message: "Invalid DateTimeScale: First and last must be DateTime or NaiveDateTime structs."
end
defimpl Plox.Scale do
def values(%{first: %DateTime{time_zone: tz}} = scale, %{step: {step_days, :day}}) when tz != "Etc/UTC" do
scale.first
|> Stream.unfold(fn current_dt ->
if DateTime.after?(current_dt, scale.last) do
nil
else
{current_dt, shift_days(current_dt, step_days)}
end
end)
|> Enum.to_list()
end
def values(scale, opts) do
%{first: %date_time_module{}} = scale
step_seconds =
case Map.get(opts, :step, {60, :second}) do
seconds when is_integer(seconds) -> seconds
{seconds, :second} -> seconds
{minutes, :minute} -> minutes * 60
{hours, :hour} -> hours * 3600
{days, :day} -> days * 86_400
end
if date_time_module == DateTime and scale.first.time_zone != "Etc/UTC" and
step_seconds > 3600 do
Logger.warning(fn ->
"DateTimeScale: steps greater than an hour in seconds for non UTC DateTimes are not safe to use because of DST shifts"
end)
end
total_seconds = date_time_module.diff(scale.last, scale.first)
ticks = trunc(total_seconds / step_seconds)
0..ticks
|> Enum.map_reduce(scale.first, fn _i, acc ->
{acc, date_time_module.add(acc, step_seconds)}
end)
|> elem(0)
end
def convert_to_range(%{first: %date_time_module{}} = scale, %date_time_module{} = value, to_range)
when date_time_module in [DateTime, NaiveDateTime] do
if date_time_module.compare(value, scale.first) == :lt or
date_time_module.compare(value, scale.last) == :gt do
raise ArgumentError,
message: "Invalid value `#{inspect(value)}` given for `#{inspect(scale)}`"
else
date_time_module.diff(value, scale.first) * (to_range.last - to_range.first) /
date_time_module.diff(scale.last, scale.first) + to_range.first
end
end
def convert_to_range(scale, value, _to_range) do
raise ArgumentError,
message: "Invalid value `#{inspect(value)}` given for `#{inspect(scale)}`"
end
defp shift_days(dt, days) do
dt
|> shift_by(days, :days)
|> then(&DateTime.from_naive!(DateTime.to_naive(&1), &1.time_zone))
end
# The below `shift` code was taken from Timex, and only supports positive
# day shifting
defp shift_by(%DateTime{} = datetime, 0, :days), do: datetime
# Positive shifts
defp shift_by(%DateTime{year: year, month: month, day: day} = datetime, value, :days) when value > 0 do
ldom = :calendar.last_day_of_the_month(year, month)
cond do
day + value <= ldom ->
%DateTime{datetime | day: day + value}
month + 1 <= 12 ->
diff = ldom - day + 1
shift_by(%DateTime{datetime | month: month + 1, day: 1}, value - diff, :days)
:else ->
diff = ldom - day + 1
shift_by(%DateTime{datetime | year: year + 1, month: 1, day: 1}, value - diff, :days)
end
end
end
# TODO: inspect for DateTime with time zones is invalid
defimpl Inspect do
def inspect(%Plox.DateTimeScale{first: first, last: last}, _) do
"Plox.DateTimeScale.new(" <> inspect(first) <> ", " <> inspect(last) <> ")"
end
end
end