peek-travel / cocktail

Elixir date recurrence library based on iCalendar events
https://hexdocs.pm/cocktail
MIT License
222 stars 30 forks source link

Daylight savings occurrences bug #304

Open gf3 opened 8 months ago

gf3 commented 8 months ago

hi all, first of all—thank you for a wonderful library!

i've encountered what seems to be a daylight savings issue in 0.10.3 when trying to generate occurences across the daylight savings boundary. when attempting to pull DateTimes after the boundary the stream seems to enter some sort of infinite loop and does not return. here's a reproduction:

{:ok, schedule} = Cocktail.Schedule.from_i_calendar("DTSTART;TZID=America/Toronto:20230101T000000\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR")
timezone = "America/Toronto"
start = ~D[2024-03-01] |> DateTime.new!(~T[00:00:00], timezone)
stop = ~D[2024-03-15] |> DateTime.new!(~T[00:00:00], timezone)
stream = schedule |> Cocktail.Schedule.end_all_recurrence_rules(stop) |> Cocktail.Schedule.occurrences(start)

note: daylight savings begins on March 10, 2024. so if we take take the first four occurrences they should fall just before the boundary:

iex> stream |> Enum.take(4)
[#DateTime<2024-03-01 00:00:00-05:00 EST America/Toronto>,
#DateTime<2024-03-04 00:00:00-05:00 EST America/Toronto>,
#DateTime<2024-03-06 00:00:00-05:00 EST America/Toronto>,
#DateTime<2024-03-08 00:00:00-05:00 EST America/Toronto>]

now, the next occurrence should fall on March 11, 2024, if we attempt to take the first five occurrences the code will hang indefinitely:

iex> stream |> Enum.take(5)

BREAK: (a)bort (A)bort with dump (c)ontinue (p)roc info (i)nfo
(l)oaded (v)ersion (k)ill (D)b-tables (d)istribution
^C
gf3 commented 8 months ago

i believe Cocktail.Validation.Day.next_time/3 is improperly returning change status:

validation = %Cocktail.Validation.Day{days: [1, 3, 5]}
timezone = "America/Toronto"
start_time = DateTime.new!(~D[2023-01-01], ~T[00:00:00], timezone)

time1 = DateTime.new!(~D[2024-03-06], ~T[00:00:00], timezone)
time2 = DateTime.new!(~D[2024-03-10], ~T[00:00:00], timezone)
iex> Cocktail.Validation.Day.next_time(validation, time1, start_time)
{:no_change, #DateTime<2024-03-06 00:00:00-05:00 EST America/Toronto>}
iex> Cocktail.Validation.Day.next_time(validation, time2, start_time)
{:change, #DateTime<2024-03-10 00:00:00-05:00 EST America/Toronto>}
gf3 commented 8 months ago

i was able to find the culprit, it's Cocktail.Util.shift_time/2:

iex> time = DateTime.new!(~D[2024-03-10], ~T[00:00:00], timezone)
#DateTime<2024-03-10 00:00:00-05:00 EST America/Toronto>

iex> Cocktail.Util.shift_time(time, days: 1)
#DateTime<2024-03-10 23:00:00-04:00 EDT America/Toronto>

iex> Timex.shift(time, days: 1)
#DateTime<2024-03-11 00:00:00-04:00 EDT America/Toronto>

this is what causes the :change/:no_change infinite loop in Cocktail.RuleSet.do_next_time/3