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.
This commit is contained in:
@@ -106,15 +106,21 @@ generate_by_freq(Current, ?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount,
|
|||||||
case should_stop(Current, EndBoundary, MaxCount, Count) of
|
case should_stop(Current, EndBoundary, MaxCount, Count) of
|
||||||
true -> lists:reverse(Acc);
|
true -> lists:reverse(Acc);
|
||||||
false ->
|
false ->
|
||||||
% Если указаны дни недели, генерируем только в эти дни
|
{NewOccurrences, Next} =
|
||||||
NewOccurrences = case ByDay of
|
case ByDay of
|
||||||
[] -> [Current];
|
[] ->
|
||||||
Days -> filter_by_weekday(Current, Days)
|
{[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,
|
end,
|
||||||
|
%% Prepend reversed batch so final lists:reverse/1 yields chronological order.
|
||||||
generate_by_freq(
|
generate_by_freq(
|
||||||
add_weeks(Current, Interval),
|
Next,
|
||||||
?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount,
|
?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount,
|
||||||
Count + 1, NewOccurrences ++ Acc
|
Count + 1, lists:reverse(NewOccurrences) ++ Acc
|
||||||
)
|
)
|
||||||
end;
|
end;
|
||||||
|
|
||||||
@@ -151,9 +157,56 @@ add_months({{Y, M, D}, Time}, N) ->
|
|||||||
NewDay = minus(D, calendar:last_day_of_the_month(NewYear, NewMonth)),
|
NewDay = minus(D, calendar:last_day_of_the_month(NewYear, NewMonth)),
|
||||||
{{NewYear, NewMonth, NewDay}, Time}.
|
{{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].
|
[DateTime].
|
||||||
|
|
||||||
filter_by_month_day(DateTime, _Days) ->
|
filter_by_month_day(DateTime, _Days) ->
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ logic_recurrence_test_() ->
|
|||||||
{"Validate RRULE test", fun test_validate_rrule/0},
|
{"Validate RRULE test", fun test_validate_rrule/0},
|
||||||
{"Generate daily occurrences test", fun test_daily_occurrences/0},
|
{"Generate daily occurrences test", fun test_daily_occurrences/0},
|
||||||
{"Generate weekly occurrences test", fun test_weekly_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 monthly occurrences test", fun test_monthly_occurrences/0},
|
||||||
{"Generate with count limit test", fun test_count_limit/0},
|
{"Generate with count limit test", fun test_count_limit/0},
|
||||||
{"Generate with until limit test", fun test_until_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, 4, 20}, {10, 0, 0}}, lists:nth(1, Occurrences)),
|
||||||
?assertEqual({{2026, 5, 11}, {10, 0, 0}}, lists:nth(4, 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() ->
|
test_monthly_occurrences() ->
|
||||||
StartTime = {{2026, 4, 20}, {10, 0, 0}},
|
StartTime = {{2026, 4, 20}, {10, 0, 0}},
|
||||||
RRule = #{
|
RRule = #{
|
||||||
|
|||||||
Reference in New Issue
Block a user