Skip to content

Commit

Permalink
Merge pull request #8 from Omerlo-Technologies/feat/excluded-dates
Browse files Browse the repository at this point in the history
Add excluded dates
  • Loading branch information
Matsa59 authored Apr 25, 2024
2 parents 79da457 + d00311f commit a0716c8
Show file tree
Hide file tree
Showing 8 changed files with 263 additions and 15 deletions.
6 changes: 1 addition & 5 deletions lib/ex_cycle.ex
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,7 @@ defmodule ExCycle do
def occurrences(%ExCycle{} = cycle, from) do
cycle
|> Map.update!(:rules, fn rules ->
Enum.map(rules, fn rule ->
rule
|> Map.update!(:state, &ExCycle.State.set_next(&1, from))
|> Rule.next()
end)
Enum.map(rules, &Rule.init(&1, from))
end)
|> Stream.unfold(&get_next_occurrence/1)
end
Expand Down
44 changes: 43 additions & 1 deletion lib/ex_cycle/rule.ex
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ defmodule ExCycle.Rule do
{start_at, opts} = Keyword.pop_lazy(opts, :starts_at, &NaiveDateTime.utc_now/0)
{timezone, opts} = Keyword.pop(opts, :timezone)

opts = Keyword.put(opts, frequency, interval)
opts =
opts
|> Keyword.update(:excluded_dates, [], &cast_excluded_dates(&1, timezone))
|> Keyword.put(frequency, interval)

%Rule{
state: ExCycle.State.new(start_at),
Expand All @@ -85,6 +88,29 @@ defmodule ExCycle.Rule do
}
end

@doc """
Initializes the rule starting from the `from` datetime.
## Examples
iex> init(%Rule{}, ~D[2024-01-01])
%ExCycle{}
iex> init(%Rule{}, ~N[2024-01-01 10:00:00])
%ExCycle{}
"""
@spec init(t(), ExCycle.datetime()) :: t()
def init(rule, from) do
rule
|> Map.update!(:state, fn state ->
state
|> ExCycle.State.set_next(from)
|> do_next(rule.validations)
end)
|> generate_result()
end

@doc """
Returns the next dates that match validations.
Expand Down Expand Up @@ -139,4 +165,20 @@ defmodule ExCycle.Rule do
defp invalid?(state, %mod{} = validation) do
!mod.valid?(state, validation)
end

defp cast_excluded_dates(dates, timezone) do
Enum.map(dates, &cast_excluded_date(&1, timezone))
end

defp cast_excluded_date(%DateTime{} = datetime, timezone) do
if timezone do
datetime
|> DateTime.shift_zone!(timezone)
|> DateTime.to_naive()
else
DateTime.to_naive(datetime)
end
end

defp cast_excluded_date(date, _timezone), do: date
end
100 changes: 93 additions & 7 deletions lib/ex_cycle/validations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule ExCycle.Validations do
"""

alias ExCycle.Validations.{
DateExclusion,
DateValidation,
HourOfDay,
Interval,
Expand All @@ -23,6 +24,7 @@ defmodule ExCycle.Validations do
| Interval.t()
| Lock.t()
| DateValidation.t()
| DateExclusion.t()

@callback valid?(ExCycle.State.t(), any_validation()) :: boolean()

Expand All @@ -31,7 +33,8 @@ defmodule ExCycle.Validations do
@validations_order [
:minute_of_hour,
:hour_of_day,
:interval
:interval,
:excluded_dates
]

@doc false
Expand All @@ -46,17 +49,96 @@ defmodule ExCycle.Validations do
@spec build(Interval.frequency(), keyword()) :: [any_validation(), ...]
def build(frequency, opts) do
validations = Enum.reduce(opts, %{}, &build_validation/2)
locks = locks_for(frequency)
locks = locks_for(frequency, Map.keys(validations))
sort(validations) ++ [DateValidation.new()] ++ locks
end

# NOTE maybe `locks_for/1` must be handle directly in the interval validations
defp locks_for(:yearly), do: [Lock.new(:month), Lock.new(:day)]
defp locks_for(:monthly), do: [Lock.new(:day)]
defp locks_for(:weekly), do: [Lock.new(:week_day)]
defp locks_for(_interval), do: []
defp locks_for(:yearly, validations) do
add_lock(:second, validations)
|> add_lock(:minute, validations)
|> add_lock(:hour, validations)
|> add_lock(:day, validations)
|> add_lock(:month, validations)
end

defp locks_for(:monthly, validations) do
add_lock(:second, validations)
|> add_lock(:minute, validations)
|> add_lock(:hour, validations)
|> add_lock(:day, validations)
end

defp locks_for(:weekly, validations) do
add_lock(:second, validations)
|> add_lock(:minute, validations)
|> add_lock(:hour, validations)
|> add_lock(:week_day, validations)
end

defp locks_for(:daily, validations) do
add_lock(:second, validations)
|> add_lock(:minute, validations)
|> add_lock(:hour, validations)
end

defp locks_for(:hourly, validations) do
add_lock(:second, validations)
|> add_lock(:minute, validations)
end

defp locks_for(:minutely, validations) do
add_lock(:second, validations)
end

defp locks_for(:secondly, _validations_names) do
[]
end

defp add_lock(locks \\ [], type, validations_names)

@exceptions [:second_of_minute]
defp add_lock(locks, :second, validations) do
if Enum.any?(validations, &(&1 in @exceptions)) do
locks
else
[Lock.new(:second) | locks]
end
end

@exceptions [:minute_of_hour, :second_of_minute]
defp add_lock(locks, :minute, validations) do
if Enum.any?(validations, &(&1 in @exceptions)) do
locks
else
[Lock.new(:minute) | locks]
end
end

@exceptions [:hour_of_day, :minute_of_hour, :second_of_minute]
defp add_lock(locks, :hour, validations) do
if Enum.any?(validations, &(&1 in @exceptions)) do
locks
else
[Lock.new(:hour) | locks]
end
end

defp add_lock(locks, :day, _validations) do
[Lock.new(:day) | locks]
end

defp add_lock(locks, :week_day, _validations) do
[Lock.new(:week_day) | locks]
end

defp add_lock(locks, :month, _validations) do
[Lock.new(:month) | locks]
end

@doc false
defp build_validation({_opt, []}, validations), do: validations
defp build_validation({_opt, nil}, validations), do: validations

defp build_validation({:minutes, minutes}, validations) do
Map.put(validations, :minute_of_hour, MinuteOfHour.new(minutes))
end
Expand All @@ -70,5 +152,9 @@ defmodule ExCycle.Validations do
Map.put(validations, :interval, Interval.new(frequency, interval))
end

defp build_validation({:excluded_dates, dates}, validations) do
Map.put(validations, :excluded_dates, DateExclusion.new(dates))
end

defp build_validation(_, validations), do: validations
end
58 changes: 58 additions & 0 deletions lib/ex_cycle/validations/date_exclusion.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
defmodule ExCycle.Validations.DateExclusion do
@moduledoc """
DateExclusion defines a list of date or datetime to exclude from generated datetimes.
## Examples
iex> %DateExclusion{dates: [~D[2024-01-01], ~N[2024-01-02 10:00:00]]}
Will exclude the date 2024-01-01 and the datetime 2024-01-02 10:00:00.
"""

@behaviour ExCycle.Validations

alias __MODULE__

@enforce_keys [:dates]
defstruct dates: []

@type t :: %DateExclusion{dates: list(Date.t() | NaiveDateTime.t())}

@spec new(list(Date.t() | NaiveDateTime.t())) :: t()
def new(dates) do
%DateExclusion{
dates: Enum.map(dates, &parse_datetime/1)
}
end

defp parse_datetime(%Date{} = date), do: date
defp parse_datetime(%NaiveDateTime{} = datetime), do: datetime

@impl ExCycle.Validations
@spec valid?(ExCycle.State.t(), t()) :: boolean()
def valid?(datetime_state, %DateExclusion{dates: excluded_dates}) do
!Enum.any?(excluded_dates, &datetimes_equal?(&1, datetime_state.next))
end

@impl ExCycle.Validations
@spec next(ExCycle.State.t(), t()) :: ExCycle.State.t()
def next(datetime_state, %DateExclusion{dates: excluded_dates}) do
excluded_date = Enum.find(excluded_dates, &datetimes_equal?(&1, datetime_state.next))

shift =
case excluded_date do
%Date{} -> &(&1 |> Date.add(1) |> NaiveDateTime.new!(~T[00:00:00]))
%NaiveDateTime{} -> &NaiveDateTime.add(&1, 1, :second)
end

ExCycle.State.update_next(datetime_state, shift)
end

defp datetimes_equal?(datetime, next_datetime) do
case datetime do
%Date{} -> Date.compare(next_datetime, datetime) == :eq
%NaiveDateTime{} -> NaiveDateTime.compare(next_datetime, datetime) == :eq
end
end
end
2 changes: 1 addition & 1 deletion lib/ex_cycle/validations/lock.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ defmodule ExCycle.Validations.Lock do
end

def next(state, %Lock{unit: :hour}) do
diff = state.origin.hour - state.next.hour + 24
diff = rem(state.origin.hour - state.next.hour + 24, 24)
ExCycle.State.update_next(state, &NaiveDateTime.add(&1, diff, :hour))
end

Expand Down
4 changes: 3 additions & 1 deletion test/ex_cycle/rule_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ defmodule ExCycle.RuleTest do
expected_validations = [
%ExCycle.Validations.HourOfDay{hours: [10, 20]},
%ExCycle.Validations.Interval{frequency: :daily, value: 2},
%ExCycle.Validations.DateValidation{}
%ExCycle.Validations.DateValidation{},
%ExCycle.Validations.Lock{unit: :minute},
%ExCycle.Validations.Lock{unit: :second}
]

assert rule.validations == expected_validations
Expand Down
39 changes: 39 additions & 0 deletions test/ex_cycle/validations/date_exclusion_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
defmodule ExCycle.Validations.DateExclusionTest do
use ExUnit.Case, async: true
alias ExCycle.Validations.DateExclusion

setup do
%{state: ExCycle.State.new(~N[2024-04-04 10:00:00])}
end

describe "valid?/2" do
test "for valid state", %{state: state} do
validation = DateExclusion.new([])
assert DateExclusion.valid?(state, validation)
end

test "for excluded date", %{state: state} do
validation = DateExclusion.new([~D[2024-04-04]])
refute DateExclusion.valid?(state, validation)
end

test "for excluded datetime", %{state: state} do
validation = DateExclusion.new([~N[2024-04-04 10:00:00]])
refute DateExclusion.valid?(state, validation)
end
end

describe "next/2" do
test "for date", %{state: state} do
validation = DateExclusion.new([~D[2024-04-04]])
new_state = DateExclusion.next(state, validation)
assert new_state.next == ~N[2024-04-05 00:00:00]
end

test "for datetime", %{state: state} do
validation = DateExclusion.new([~N[2024-04-04 10:00:00]])
new_state = DateExclusion.next(state, validation)
assert new_state.next == ~N[2024-04-04 10:00:01]
end
end
end
25 changes: 25 additions & 0 deletions test/ex_cycle_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@ defmodule ExCycleTest do
end
end

describe "excluded dates" do
test "with dates" do
datetimes =
ExCycle.new()
|> ExCycle.add_rule(:daily, excluded_dates: [~D[2024-01-02]], starts_at: ~D[2024-01-01])
|> ExCycle.occurrences(~D[2024-01-01])
|> Enum.take(2)

assert datetimes == [~N[2024-01-01 00:00:00], ~N[2024-01-03 00:00:00]]
end

test "with datetimes" do
datetimes =
ExCycle.new()
|> ExCycle.add_rule(:daily,
excluded_dates: [~N[2024-01-02 10:00:00]],
starts_at: ~N[2024-01-01 10:00:00]
)
|> ExCycle.occurrences(~D[2024-01-01])
|> Enum.take(2)

assert datetimes == [~N[2024-01-01 10:00:00], ~N[2024-01-03 10:00:00]]
end
end

describe "duration" do
test "every day with 1h of duration" do
spans =
Expand Down

0 comments on commit a0716c8

Please sign in to comment.