diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..8a05082 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,3 @@ +import Config + +config :elixir, :time_zone_database, Tz.TimeZoneDatabase diff --git a/lib/crontab/date_checker.ex b/lib/crontab/date_checker.ex index 3d4df08..e87990c 100644 --- a/lib/crontab/date_checker.ex +++ b/lib/crontab/date_checker.ex @@ -27,7 +27,8 @@ defmodule Crontab.DateChecker do true """ - @spec matches_date?(cron_expression :: CronExpression.t(), date :: date) :: boolean | no_return + @spec matches_date?(cron_expression :: CronExpression.t(), date :: date) :: + boolean | no_return def matches_date?(cron_expression_or_condition_list, date) def matches_date?(%CronExpression{reboot: true}, _), @@ -39,7 +40,10 @@ defmodule Crontab.DateChecker do |> matches_date?(execution_date) end - @spec matches_date?(condition_list :: CronExpression.condition_list(), date :: date) :: boolean + @spec matches_date?( + condition_list :: CronExpression.condition_list(), + date :: date + ) :: boolean def matches_date?([], _), do: true def matches_date?([{interval, conditions} | tail], execution_date) do diff --git a/lib/crontab/date_helper.ex b/lib/crontab/date_helper.ex index bbf4c47..2834b74 100644 --- a/lib/crontab/date_helper.ex +++ b/lib/crontab/date_helper.ex @@ -3,10 +3,12 @@ defmodule Crontab.DateHelper do @type unit :: :year | :month | :day | :hour | :minute | :second | :microsecond + @type date :: NaiveDateTime.t() | DateTime.t() + @units [ {:year, {nil, nil}}, {:month, {1, 12}}, - {:day, {1, :end_onf_month}}, + {:day, {1, :end_of_month}}, {:hour, {0, 23}}, {:minute, {0, 59}}, {:second, {0, 59}}, @@ -21,8 +23,11 @@ defmodule Crontab.DateHelper do iex> Crontab.DateHelper.beginning_of(~N[2016-03-14 01:45:45.123], :year) ~N[2016-01-01 00:00:00] + iex> Crontab.DateHelper.beginning_of(~U[2016-03-14 01:45:45.123Z], :year) + ~U[2016-01-01 00:00:00Z] + """ - @spec beginning_of(NaiveDateTime.t(), unit) :: NaiveDateTime.t() + @spec beginning_of(date, unit :: unit) :: date when date: date def beginning_of(date, unit) do _beginning_of(date, proceeding_units(unit)) end @@ -35,16 +40,28 @@ defmodule Crontab.DateHelper do iex> Crontab.DateHelper.end_of(~N[2016-03-14 01:45:45.123], :year) ~N[2016-12-31 23:59:59.999999] + iex> Crontab.DateHelper.end_of(~U[2016-03-14 01:45:45.123Z], :year) + ~U[2016-12-31 23:59:59.999999Z] + """ - @spec end_of(NaiveDateTime.t(), unit) :: NaiveDateTime.t() + @spec end_of(date, unit :: unit) :: date when date: date def end_of(date, unit) do _end_of(date, proceeding_units(unit)) end @doc """ - Find the last occurrence of weekday in month. + Find last occurrence of weekday in month + + ### Examples: + + iex> Crontab.DateHelper.last_weekday(~N[2016-03-14 01:45:45.123], 6) + 26 + + iex> Crontab.DateHelper.last_weekday(~U[2016-03-14 01:45:45.123Z], 6) + 26 + """ - @spec last_weekday(NaiveDateTime.t(), Calendar.day_of_week()) :: Calendar.day() + @spec last_weekday(date :: date, day_of_week :: Calendar.day_of_week()) :: Calendar.day() def last_weekday(date, weekday) do date |> end_of(:month) @@ -52,9 +69,19 @@ defmodule Crontab.DateHelper do end @doc """ - Find the nth weekday of month. + Find nth weekday of month + + ### Examples: + + iex> Crontab.DateHelper.nth_weekday(~N[2016-03-14 01:45:45.123], 6, 2) + 12 + + iex> Crontab.DateHelper.nth_weekday(~U[2016-03-14 01:45:45.123Z], 6, 2) + 12 + """ - @spec nth_weekday(NaiveDateTime.t(), Calendar.day_of_week(), integer) :: Calendar.day() + @spec nth_weekday(date :: date, weekday :: Calendar.day_of_week(), n :: pos_integer) :: + Calendar.day() def nth_weekday(date, weekday, n) do date |> beginning_of(:month) @@ -62,93 +89,145 @@ defmodule Crontab.DateHelper do end @doc """ - Find the last occurrence of weekday in month. + Find last occurrence of weekday in month + + ### Examples: + + iex> Crontab.DateHelper.last_weekday_of_month(~N[2016-03-14 01:45:45.123]) + 31 + + iex> Crontab.DateHelper.last_weekday_of_month(~U[2016-03-14 01:45:45.123Z]) + 31 + """ - @spec last_weekday_of_month(NaiveDateTime.t()) :: Calendar.day() - def last_weekday_of_month(date) do - last_weekday_of_month(end_of(date, :month), :end) - end + @spec last_weekday_of_month(date :: date()) :: Calendar.day() + def last_weekday_of_month(date), do: last_weekday_of_month(end_of(date, :month), :end) @doc """ - Find the next occurrence of weekday relative to date. + Find next occurrence of weekday relative to date + + ### Examples: + + iex> Crontab.DateHelper.next_weekday_to(~N[2016-03-14 01:45:45.123]) + 14 + + iex> Crontab.DateHelper.next_weekday_to(~U[2016-03-14 01:45:45.123Z]) + 14 + """ - @spec next_weekday_to(NaiveDateTime.t()) :: Calendar.day() - def next_weekday_to(date = %NaiveDateTime{year: year, month: month, day: day}) do - weekday = :calendar.day_of_the_week(year, month, day) - next_day = NaiveDateTime.add(date, 1, :day) - previous_day = NaiveDateTime.add(date, -1, :day) + @spec next_weekday_to(date :: date) :: Calendar.day() + def next_weekday_to(date) do + weekday = Date.day_of_week(date) + next_day = add(date, 1, :day) + previous_day = add(date, -1, :day) cond do weekday == 7 && next_day.month == date.month -> next_day.day - weekday == 7 -> NaiveDateTime.add(date, -2, :day).day + weekday == 7 -> add(date, -2, :day).day weekday == 6 && previous_day.month == date.month -> previous_day.day - weekday == 6 -> NaiveDateTime.add(date, 2, :day).day + weekday == 6 -> add(date, 2, :day).day true -> date.day end end - @spec inc_year(NaiveDateTime.t()) :: NaiveDateTime.t() - def inc_year(date) do - leap_year? = - date - |> NaiveDateTime.to_date() - |> Date.leap_year?() + @doc """ + Increment Year - if leap_year? do - NaiveDateTime.add(date, 366, :day) - else - NaiveDateTime.add(date, 365, :day) - end + ### Examples: + + iex> Crontab.DateHelper.inc_year(~N[2016-03-14 01:45:45.123]) + ~N[2017-03-14 01:45:45.123] + + iex> Crontab.DateHelper.inc_year(~U[2016-03-14 01:45:45.123Z]) + ~U[2017-03-14 01:45:45.123Z] + + """ + @spec inc_year(date) :: date when date: date + def inc_year(date = %{month: 2, day: 29}), do: add(date, 365, :day) + + def inc_year(date = %{month: month}) do + candidate = add(date, 365, :day) + date_leap_year_before_mar? = Date.leap_year?(date) and month < 3 + candidate_leap_year_after_feb? = Date.leap_year?(candidate) and month > 2 + adjustment = if candidate_leap_year_after_feb? or date_leap_year_before_mar?, do: 1, else: 0 + add(candidate, adjustment, :day) end - @spec dec_year(NaiveDateTime.t()) :: NaiveDateTime.t() - def dec_year(date) do - leap_year? = - date - |> NaiveDateTime.to_date() - |> Date.leap_year?() + @doc """ + Decrement Year - if leap_year? do - NaiveDateTime.add(date, -366, :day) - else - NaiveDateTime.add(date, -365, :day) - end + ### Examples: + + iex> Crontab.DateHelper.dec_year(~N[2016-03-14 01:45:45.123]) + ~N[2015-03-14 01:45:45.123] + + iex> Crontab.DateHelper.dec_year(~U[2016-03-14 01:45:45.123Z]) + ~U[2015-03-14 01:45:45.123Z] + + """ + @spec dec_year(date) :: date when date: date + def dec_year(date = %{month: 2, day: 29}), do: add(date, -366, :day) + + def dec_year(date = %{month: month}) do + candidate = add(date, -365, :day) + date_leap_year_after_mar? = Date.leap_year?(date) and month > 2 + candidate_leap_year_before_feb? = Date.leap_year?(candidate) and month < 3 + adjustment = if date_leap_year_after_mar? or candidate_leap_year_before_feb?, do: -1, else: 0 + add(candidate, adjustment, :day) end - @spec inc_month(NaiveDateTime.t()) :: NaiveDateTime.t() - def inc_month(date = %NaiveDateTime{day: day}) do + @doc """ + Increment Month + + ### Examples: + + iex> Crontab.DateHelper.inc_month(~N[2016-03-14 01:45:45.123]) + ~N[2016-04-01 01:45:45.123] + + iex> Crontab.DateHelper.inc_month(~U[2016-03-14 01:45:45.123Z]) + ~U[2016-04-01 01:45:45.123Z] + + """ + @spec inc_month(date) :: date when date: date + def inc_month(date = %{year: year, month: month, day: day}) do days = - date - |> NaiveDateTime.to_date() + Date.new!(year, month, day) |> Date.days_in_month() - NaiveDateTime.add(date, days + 1 - day, :day) + add(date, days + 1 - day, :day) end - @spec dec_month(NaiveDateTime.t()) :: NaiveDateTime.t() - def dec_month(date) do - days = - date - |> NaiveDateTime.to_date() - |> Date.days_in_month() + @doc """ + Decrement Month + + ### Examples: + + iex> Crontab.DateHelper.dec_month(~N[2016-03-14 01:45:45.123]) + ~N[2016-02-14 01:45:45.123] - NaiveDateTime.add(date, -days, :day) + iex> Crontab.DateHelper.dec_month(~U[2016-03-14 01:45:45.123Z]) + ~U[2016-02-14 01:45:45.123Z] + + iex> Crontab.DateHelper.dec_month(~N[2011-05-31 23:59:59]) + ~N[2011-04-30 23:59:59] + + """ + @spec dec_month(date) :: date when date: date + def dec_month(date = %{year: year, month: month, day: day}) do + days_in_last_month = Date.new!(year, month, 1) |> Date.add(-1) |> Date.days_in_month() + add(date, -(day + max(days_in_last_month - day, 0)), :day) end - @spec _beginning_of(NaiveDateTime.t(), [{unit, {any, any}}]) :: NaiveDateTime.t() + @spec _beginning_of(date, [{unit, {any, any}}]) :: date when date: date defp _beginning_of(date, [{unit, {lower, _}} | tail]) do _beginning_of(Map.put(date, unit, lower), tail) end defp _beginning_of(date, []), do: date - @spec _end_of(NaiveDateTime.t(), [{unit, {any, any}}]) :: NaiveDateTime.t() - defp _end_of(date, [{unit, {_, :end_onf_month}} | tail]) do - upper = - date - |> NaiveDateTime.to_date() - |> Date.days_in_month() - + @spec _end_of(date, [{unit, {any, any}}]) :: date when date: date + defp _end_of(date, [{unit, {_, :end_of_month}} | tail]) do + upper = Date.days_in_month(date) _end_of(Map.put(date, unit, upper), tail) end @@ -165,7 +244,7 @@ defmodule Crontab.DateHelper do |> Enum.reduce([], fn {key, value}, acc -> cond do Enum.count(acc) > 0 -> - Enum.concat(acc, [{key, value}]) + [{key, value} | acc] key == unit -> [{key, value}] @@ -174,39 +253,54 @@ defmodule Crontab.DateHelper do [] end end) + |> Enum.reverse() units end - @spec nth_weekday(NaiveDateTime.t(), Calendar.day_of_week(), :start) :: boolean - defp nth_weekday(date = %NaiveDateTime{}, _, 0, :start), - do: NaiveDateTime.add(date, -1, :day).day + @spec nth_weekday(date :: date, weekday :: Calendar.day_of_week(), position :: :start) :: + boolean + defp nth_weekday(date, _, 0, :start), do: add(date, -1, :day).day - defp nth_weekday(date = %NaiveDateTime{year: year, month: month, day: day}, weekday, n, :start) do - if :calendar.day_of_the_week(year, month, day) == weekday do - nth_weekday(NaiveDateTime.add(date, 1, :day), weekday, n - 1, :start) - else - nth_weekday(NaiveDateTime.add(date, 1, :day), weekday, n, :start) - end + defp nth_weekday(date, weekday, n, :start) do + modifier = if Date.day_of_week(date) == weekday, do: n - 1, else: n + nth_weekday(add(date, 1, :day), weekday, modifier, :start) end - @spec last_weekday_of_month(NaiveDateTime.t(), :end) :: Calendar.day() - defp last_weekday_of_month(date = %NaiveDateTime{year: year, month: month, day: day}, :end) do - weekday = :calendar.day_of_the_week(year, month, day) - - if weekday > 5 do - last_weekday_of_month(NaiveDateTime.add(date, -1, :day), :end) + @spec last_weekday_of_month(date :: date(), position :: :end) :: Calendar.day() + defp last_weekday_of_month(date = %{day: day}, :end) do + if Date.day_of_week(date) > 5 do + last_weekday_of_month(add(date, -1, :day), :end) else day end end - @spec last_weekday(NaiveDateTime.t(), non_neg_integer, :end) :: Calendar.day() - defp last_weekday(date = %NaiveDateTime{year: year, month: month, day: day}, weekday, :end) do - if :calendar.day_of_the_week(year, month, day) == weekday do + @spec last_weekday(date :: date, weekday :: Calendar.day_of_week(), position :: :end) :: + Calendar.day() + defp last_weekday(date = %{day: day}, weekday, :end) do + if Date.day_of_week(date) == weekday do day else - last_weekday(NaiveDateTime.add(date, -1, :day), weekday, :end) + last_weekday(add(date, -1, :day), weekday, :end) + end + end + + @doc false + def add(datetime = %NaiveDateTime{}, amt, unit), do: NaiveDateTime.add(datetime, amt, unit) + + def add(datetime = %DateTime{}, amt, unit) do + candidate = DateTime.add(datetime, amt, unit) + adjustment = datetime.std_offset - candidate.std_offset + adjusted = DateTime.add(candidate, adjustment, :second) + + if adjusted.std_offset != candidate.std_offset do + candidate + else + case DateTime.from_naive(DateTime.to_naive(adjusted), adjusted.time_zone) do + {:ambiguous, _, target} -> target + {:ok, target} -> target + end end end end diff --git a/mix.exs b/mix.exs index 59014c5..5ed6d02 100644 --- a/mix.exs +++ b/mix.exs @@ -43,7 +43,9 @@ defmodule Crontab.Mixfile do {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:excoveralls, "~> 0.5", only: [:test], runtime: false}, {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, - {:credo, "~> 1.0", only: [:dev], runtime: false} + {:credo, "~> 1.0", only: [:dev], runtime: false}, + {:mix_test_watch, "~> 1.1", only: [:dev, :test], runtime: false}, + {:tz, "~> 0.26", only: [:dev, :test]} ] end diff --git a/test/crontab/date_helper_test.exs b/test/crontab/date_helper_test.exs index f1bfe44..e91b4f2 100644 --- a/test/crontab/date_helper_test.exs +++ b/test/crontab/date_helper_test.exs @@ -4,10 +4,124 @@ defmodule Crontab.DateHelperTest do use ExUnit.Case, async: true doctest Crontab.DateHelper + alias Crontab.DateHelper describe "inc_month/1" do - test "does not jump obver month" do - assert Crontab.DateHelper.inc_month(~N[2019-05-31 23:00:00]) == ~N[2019-06-01 23:00:00] + test "does not jump over month" do + assert DateHelper.inc_month(~N[2019-05-31 23:00:00]) == ~N[2019-06-01 23:00:00] + end + end + + describe "dec_year/1" do + test "non-leap year back to leap year at end feb" do + given = ~N[2025-02-28 00:00:00] + assert DateHelper.dec_year(given) == ~N[2024-02-28 00:00:00] + end + + test "leap year back to non-leap year at end feb" do + given = ~N[2024-02-29 00:00:00] + assert DateHelper.dec_year(given) == ~N[2023-02-28 00:00:00] + end + + test "non-leap year back to leap year at start mar" do + given = ~N[2025-03-01 00:00:00] + assert DateHelper.dec_year(given) == ~N[2024-03-01 00:00:00] + end + + test "leap year back to non-leap year at start mar" do + given = ~N[2024-03-01 00:00:00] + assert DateHelper.dec_year(given) == ~N[2023-03-01 00:00:00] + end + + test "non-leap year back to non-leap year" do + given = ~N[2026-03-01 00:00:00] + assert DateHelper.dec_year(given) == ~N[2025-03-01 00:00:00] + end + end + + describe "inc_year/1" do + test "non-leap year to leap year at end feb" do + given = ~N[2023-02-28 00:00:00] + assert DateHelper.inc_year(given) == ~N[2024-02-28 00:00:00] + end + + test "leap year to non-leap year at end feb" do + given = ~N[2024-02-29 00:00:00] + assert DateHelper.inc_year(given) == ~N[2025-02-28 00:00:00] + end + + test "non-leap year to leap year at start mar" do + given = ~N[2023-03-01 00:00:00] + assert DateHelper.inc_year(given) == ~N[2024-03-01 00:00:00] + end + + test "leap year to non-leap year at start mar" do + given = ~N[2024-03-01 00:00:00] + assert DateHelper.inc_year(given) == ~N[2025-03-01 00:00:00] + end + + test "non-leap year to non-leap year" do + given = ~N[2025-03-01 00:00:00] + assert DateHelper.inc_year(given) == ~N[2026-03-01 00:00:00] + end + end + + describe "add/3 on NaiveDateTime" do + test "one day to day before NY DST starts" do + date = ~N[2024-03-09 12:34:56] + assert DateHelper.add(date, 1, :day) == ~N[2024-03-10 12:34:56] + end + end + + describe "add/3 on DateTime UTC" do + test "one day to day before NY DST starts" do + date = ~U[2024-03-09 12:34:56Z] + assert DateHelper.add(date, 1, :day) == ~U[2024-03-10 12:34:56Z] + end + end + + describe "add/3 on DateTime NYT" do + test "one day to day before NY DST starts" do + day_before = DateTime.from_naive!(~N[2024-03-09 12:34:56], "America/New_York") + expected = DateTime.from_naive!(~N[2024-03-10 12:34:56], "America/New_York") + + assert DateHelper.add(day_before, 1, :day) == expected + end + + test "one day to day before NY DST ends" do + day_before = DateTime.from_naive!(~N[2024-11-02 12:34:56], "America/New_York") + expected = DateTime.from_naive!(~N[2024-11-03 12:34:56], "America/New_York") + + assert DateHelper.add(day_before, 1, :day) == expected + end + + for {unit, time} <- [{:second, ~T[03:00:00]}, {:minute, ~T[03:00:59]}, {:hour, ~T[03:59:59]}] do + test "one #{unit} to one second before NY DST starts" do + one_sec_before = DateTime.from_naive!(~N[2024-03-10 01:59:59], "America/New_York") + expected = DateTime.new!(~D[2024-03-10], unquote(Macro.escape(time)), "America/New_York") + + assert DateHelper.add(one_sec_before, 1, unquote(unit)) == expected + end + end + + for {unit, hour, minute, second} <- [ + {:second, 1, 0, 0}, + {:minute, 1, 0, 59}, + {:hour, 1, 59, 59} + ] do + test "one #{unit} to one second before NY DST ends" do + one_sec_before = DateTime.from_naive!(~N[2024-11-03 00:59:59], "America/New_York") + + # 'cos 1:00:00 to 1:59:00 can be represented as timezones for EDT and EST, + # so "work backwards" by getting the EST time from 2:00 onwards then minus 1 hour + two_plus = Time.new!(unquote(hour) + 1, unquote(minute), unquote(second)) + + expected = + DateTime.new!(~D[2024-11-03], two_plus, "America/New_York") + |> DateTime.add(-1, :hour) + + assert DateHelper.add(one_sec_before, 1, unquote(unit)) == expected + end end end end