From 0a5e86c4a0a16a8d99164b68bf6ba9c67a1501b9 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Sat, 1 Aug 2026 18:17:23 +0300 Subject: [PATCH] fix(recurrence): honor BYDAY for weekly RRULE expansion Expand weekdays in the DTSTART week and advance by Interval weeks from Monday; add unit coverage for MO/WE/FR and mid-week starts. --- src/logic/logic_recurrence.erl | 73 ++++++++++++++++++++++++---- test/unit/logic_recurrence_tests.erl | 25 ++++++++++ 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/logic/logic_recurrence.erl b/src/logic/logic_recurrence.erl index b6ba352..2f1a0e9 100644 --- a/src/logic/logic_recurrence.erl +++ b/src/logic/logic_recurrence.erl @@ -106,15 +106,21 @@ generate_by_freq(Current, ?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount, case should_stop(Current, EndBoundary, MaxCount, Count) of true -> lists:reverse(Acc); false -> - % Если указаны дни недели, генерируем только в эти дни - NewOccurrences = case ByDay of - [] -> [Current]; - Days -> filter_by_weekday(Current, Days) - end, + {NewOccurrences, Next} = + case ByDay of + [] -> + {[Current], add_weeks(Current, Interval)}; + Days -> + %% BYDAY: expand weekdays in Current's week (on/after Current), + %% then jump Interval weeks from that week's Monday. + Occs = [O || O <- filter_by_weekday(Current, Days), O =< EndBoundary], + {Occs, add_weeks(week_start_monday(Current), Interval)} + end, + %% Prepend reversed batch so final lists:reverse/1 yields chronological order. generate_by_freq( - add_weeks(Current, Interval), + Next, ?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount, - Count + 1, NewOccurrences ++ Acc + Count + 1, lists:reverse(NewOccurrences) ++ Acc ) end; @@ -151,9 +157,56 @@ add_months({{Y, M, D}, Time}, N) -> NewDay = minus(D, calendar:last_day_of_the_month(NewYear, NewMonth)), {{NewYear, NewMonth, NewDay}, Time}. -filter_by_weekday(DateTime, _Days) -> - % Упрощённая версия — всегда возвращаем текущую дату - % В полной версии нужно проверять день недели +%% BYDAY codes (RFC 5545): MO..SU. calendar:day_of_the_week = 1=Mon .. 7=Sun. +week_start_monday({{Y, M, D}, Time}) -> + Dow = calendar:day_of_the_week({Y, M, D}), + add_days({{Y, M, D}, Time}, -(Dow - 1)). + +byday_to_dow(<<"MO">>) -> 1; +byday_to_dow(<<"TU">>) -> 2; +byday_to_dow(<<"WE">>) -> 3; +byday_to_dow(<<"TH">>) -> 4; +byday_to_dow(<<"FR">>) -> 5; +byday_to_dow(<<"SA">>) -> 6; +byday_to_dow(<<"SU">>) -> 7; +byday_to_dow("MO") -> 1; +byday_to_dow("TU") -> 2; +byday_to_dow("WE") -> 3; +byday_to_dow("TH") -> 4; +byday_to_dow("FR") -> 5; +byday_to_dow("SA") -> 6; +byday_to_dow("SU") -> 7; +byday_to_dow(mo) -> 1; +byday_to_dow(tu) -> 2; +byday_to_dow(we) -> 3; +byday_to_dow(th) -> 4; +byday_to_dow(fr) -> 5; +byday_to_dow(sa) -> 6; +byday_to_dow(su) -> 7; +byday_to_dow(_) -> undefined. + +%% Occurrences for BYDAY in the week of Current, same time-of-day. +%% Only dates on or after Current (so DTSTART mid-week does not emit earlier weekdays). +filter_by_weekday(Current, Days) when is_list(Days) -> + Monday = week_start_monday(Current), + Occs = + lists:foldl( + fun(DayCode, Acc) -> + case byday_to_dow(DayCode) of + undefined -> Acc; + TargetDow -> + Occ = add_days(Monday, TargetDow - 1), + case Occ >= Current of + true -> [Occ | Acc]; + false -> Acc + end + end + end, + [], + Days + ), + lists:sort(Occs); +filter_by_weekday(DateTime, _) -> [DateTime]. filter_by_month_day(DateTime, _Days) -> diff --git a/test/unit/logic_recurrence_tests.erl b/test/unit/logic_recurrence_tests.erl index b670364..bacc4d0 100644 --- a/test/unit/logic_recurrence_tests.erl +++ b/test/unit/logic_recurrence_tests.erl @@ -7,6 +7,7 @@ logic_recurrence_test_() -> {"Validate RRULE test", fun test_validate_rrule/0}, {"Generate daily occurrences test", fun test_daily_occurrences/0}, {"Generate weekly occurrences test", fun test_weekly_occurrences/0}, + {"Generate weekly BYDAY occurrences test", fun test_weekly_byday_occurrences/0}, {"Generate monthly occurrences test", fun test_monthly_occurrences/0}, {"Generate with count limit test", fun test_count_limit/0}, {"Generate with until limit test", fun test_until_limit/0} @@ -71,6 +72,30 @@ test_weekly_occurrences() -> ?assertEqual({{2026, 4, 20}, {10, 0, 0}}, lists:nth(1, Occurrences)), ?assertEqual({{2026, 5, 11}, {10, 0, 0}}, lists:nth(4, Occurrences)). +test_weekly_byday_occurrences() -> + %% Monday start, MO/WE/FR for two weeks → 6 slots + StartTime = {{2026, 4, 20}, {10, 0, 0}}, + RRule = #{ + freq => <<"WEEKLY">>, + interval => 1, + byday => [<<"MO">>, <<"WE">>, <<"FR">>] + }, + RangeEnd = {{2026, 5, 1}, {10, 0, 0}}, + + Occurrences = logic_recurrence:generate_occurrences(StartTime, RRule, RangeEnd), + ?assertEqual(6, length(Occurrences)), + ?assertEqual({{2026, 4, 20}, {10, 0, 0}}, lists:nth(1, Occurrences)), + ?assertEqual({{2026, 4, 22}, {10, 0, 0}}, lists:nth(2, Occurrences)), + ?assertEqual({{2026, 4, 24}, {10, 0, 0}}, lists:nth(3, Occurrences)), + ?assertEqual({{2026, 4, 27}, {10, 0, 0}}, lists:nth(4, Occurrences)), + ?assertEqual({{2026, 4, 29}, {10, 0, 0}}, lists:nth(5, Occurrences)), + ?assertEqual({{2026, 5, 1}, {10, 0, 0}}, lists:nth(6, Occurrences)), + + %% Mid-week DTSTART must not emit earlier weekdays of the same week + WedStart = {{2026, 4, 22}, {10, 0, 0}}, + MidWeek = logic_recurrence:generate_occurrences(WedStart, RRule, {{2026, 4, 24}, {10, 0, 0}}), + ?assertEqual([{{2026, 4, 22}, {10, 0, 0}}, {{2026, 4, 24}, {10, 0, 0}}], MidWeek). + test_monthly_occurrences() -> StartTime = {{2026, 4, 20}, {10, 0, 0}}, RRule = #{