feat: recurrence rule editing in EventEditor and ordinal BYDAY support

Add recurrence_frequency, recurrence_interval, recurrence_count, and
recurrence_by_weekday editor fields. Frequency cycles with Space.
BYDAY input accepts comma-separated names and ordinal prefixes (1MO).

Extend RecurrenceRule::by_weekday from std::chrono::weekday to ByWeekday
{ordinal, day} struct. Parser accepts ordinal BYDAY (+1MO, -1FR).
Remove strict BY*/FREQ pairing constraint to match RFC 5545.
This commit is contained in:
2026-07-23 18:45:50 +01:00
parent 40fc4651cb
commit 6c1f58ffb0
10 changed files with 499 additions and 34 deletions

View File

@@ -61,7 +61,7 @@ void check_invalid_argument(Function&& function, const std::string_view message)
recurrence.interval = 2;
recurrence.count = 6;
recurrence.until = at(24);
recurrence.by_weekday = {Monday, Wednesday};
recurrence.by_weekday = {{0, Monday}, {0, Wednesday}};
recurrence.by_month_day = {1, -1};
recurrence.by_month = {1, 7};
event.recurrence = std::move(recurrence);

View File

@@ -384,7 +384,7 @@ void test_weekly_count_and_exdates()
nocal::RecurrenceRule rule;
rule.frequency = nocal::RecurrenceFrequency::weekly;
rule.count = 6U;
rule.by_weekday = {Monday, Wednesday, Friday};
rule.by_weekday = {{0, Monday}, {0, Wednesday}, {0, Friday}};
auto weekly = utc_recurring("weekly", monday, 9, monday, 10, rule);
weekly.recurrence_exceptions = {weekly.start,
utc_time(2026y / July / 10d, 9)};

View File

@@ -315,6 +315,182 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap()
"a nonexistent source-zone civil time in the DST gap is rejected");
}
void test_recurrence_frequency_cycling()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
(void)editor.handle_key("Weekly meeting");
focus(editor, nocal::tui::EditorField::recurrence_frequency);
// Default is "none"
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "none",
"new event starts with no recurrence");
// Space cycles through frequencies
(void)editor.handle_key(" ");
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "daily",
"Space cycles to daily");
(void)editor.handle_key(" ");
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "weekly",
"Space cycles to weekly");
(void)editor.handle_key(" ");
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "monthly",
"Space cycles to monthly");
(void)editor.handle_key(" ");
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "yearly",
"Space cycles to yearly");
(void)editor.handle_key(" ");
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "none",
"Space wraps around to none");
}
void test_recurrence_submit()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
// Test: daily recurrence with count
{
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("Daily standup");
focus(editor, nocal::tui::EditorField::recurrence_frequency);
(void)editor.handle_key(" "); // -> daily
focus(editor, nocal::tui::EditorField::recurrence_count);
replace_field(editor, "5");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"daily with count=5 submits");
check(editor.result().recurrence.has_value(), "recurrence should be set");
check(editor.result().recurrence->frequency == nocal::RecurrenceFrequency::daily,
"frequency is daily");
check(editor.result().recurrence->interval == 1, "default interval is 1");
check(editor.result().recurrence->count == 5, "count is 5");
}
// Test: weekly recurrence (no by_weekday -> fills with DTSTART's weekday)
{
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("Weekly sync");
focus(editor, nocal::tui::EditorField::recurrence_frequency);
(void)editor.handle_key(" "); // -> daily
(void)editor.handle_key(" "); // -> weekly
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"weekly without BYDAY submits");
check(editor.result().recurrence.has_value(), "recurrence should be set");
check(editor.result().recurrence->frequency == nocal::RecurrenceFrequency::weekly,
"frequency is weekly");
check(!editor.result().recurrence->by_weekday.empty(),
"weekly without BYDAY should auto-fill with start weekday");
// July 17, 2026 is a Friday
check(editor.result().recurrence->by_weekday[0].day == std::chrono::Friday,
"auto-filled weekday should be Friday for 2026-07-17");
}
// Test: no recurrence (none)
{
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("One-off meeting");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"event with frequency=none submits");
check(!editor.result().recurrence.has_value(),
"frequency=none should clear recurrence");
}
// Test: invalid interval
{
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("Bad interval");
focus(editor, nocal::tui::EditorField::recurrence_frequency);
(void)editor.handle_key(" "); // -> daily
focus(editor, nocal::tui::EditorField::recurrence_interval);
replace_field(editor, "0");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
editor.error().find("Interval") != std::string_view::npos,
"interval 0 is rejected");
}
}
void test_edit_recurring_event_populates_fields()
{
using namespace std::chrono;
nocal::Event event;
event.uid = "recurring-edit-test";
event.title = "Original recurring";
event.start = sys_days{year{2026}/July/17} + 9h;
event.end = sys_days{year{2026}/July/17} + 10h;
event.recurrence = nocal::RecurrenceRule{
.frequency = nocal::RecurrenceFrequency::weekly,
.interval = 2,
.count = 10,
.until = std::nullopt,
.by_weekday = {{0, std::chrono::Monday}, {0, std::chrono::Wednesday}, {0, std::chrono::Friday}},
.by_month_day = {},
.by_month = {},
};
nocal::tui::EventEditor editor{event};
check(editor.field_value(nocal::tui::EditorField::recurrence_frequency) == "weekly",
"edit populates recurrence frequency from existing rule");
check(editor.field_value(nocal::tui::EditorField::recurrence_interval) == "2",
"edit populates recurrence interval from existing rule");
check(editor.field_value(nocal::tui::EditorField::recurrence_count) == "10",
"edit populates recurrence count from existing rule");
check(editor.field_value(nocal::tui::EditorField::recurrence_by_weekday) == "MO,WE,FR",
"edit populates BYDAY from existing rule");
}
void test_rendering_shows_recurrence()
{
using namespace std::chrono;
nocal::tui::EventEditor editor{nocal::Date{2026y / July / 17d}};
const auto plain = editor.render(72, 22, false);
check(plain.find("Repeat") != std::string::npos,
"render shows Recurrence label");
check(plain.find("none") != std::string::npos,
"render shows default frequency as none");
// Cycle to weekly and check render
focus(editor, nocal::tui::EditorField::recurrence_frequency);
(void)editor.handle_key(" "); // -> daily
(void)editor.handle_key(" "); // -> weekly
const auto with_recurrence = editor.render(72, 22, false);
check(with_recurrence.find("weekly") != std::string::npos,
"render shows weekly when set");
check(with_recurrence.find("Interval") != std::string::npos,
"render shows Interval when frequency is set");
check(with_recurrence.find("Count") != std::string::npos,
"render shows Count when frequency is set");
check(with_recurrence.find("On days") != std::string::npos,
"render shows On days when frequency is set");
}
void test_recurrence_weekly_by_weekday_input()
{
using namespace std::chrono;
const nocal::Date day{2026y / July / 17d};
nocal::tui::EventEditor editor{day};
(void)editor.handle_key("Custom weekly");
focus(editor, nocal::tui::EditorField::recurrence_frequency);
(void)editor.handle_key(" "); // -> daily
(void)editor.handle_key(" "); // -> weekly
focus(editor, nocal::tui::EditorField::recurrence_by_weekday);
replace_field(editor, "MO,WE,FR");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"custom weekly with MO,WE,FR submits");
check(editor.result().recurrence.has_value(), "recurrence should be set");
check(editor.result().recurrence->by_weekday.size() == 3,
"three BYDAY entries expected");
check(editor.result().recurrence->by_weekday[0].day == std::chrono::Monday,
"first BYDAY is Monday");
check(editor.result().recurrence->by_weekday[1].day == std::chrono::Wednesday,
"second BYDAY is Wednesday");
check(editor.result().recurrence->by_weekday[2].day == std::chrono::Friday,
"third BYDAY is Friday");
}
} // namespace
int main()
@@ -326,6 +502,11 @@ int main()
test_unicode_backspace_navigation_and_cancel();
test_rendering();
test_zoned_edit_preserves_series_and_rejects_dst_gap();
test_recurrence_frequency_cycling();
test_recurrence_submit();
test_edit_recurring_event_populates_fields();
test_rendering_shows_recurrence();
test_recurrence_weekly_by_weekday_input();
if (failures != 0) {
std::cerr << failures << " editor test(s) failed\n";
return EXIT_FAILURE;

View File

@@ -512,7 +512,7 @@ void test_supported_recurrence_and_exdates(const std::filesystem::path& root) {
"multiple/comma-separated EXDATE values were not collected");
const auto& weekly = *loaded.events[1].recurrence;
require(weekly.frequency == nocal::RecurrenceFrequency::weekly
&& weekly.by_weekday == std::vector<weekday>{Monday, Wednesday, Friday}
&& weekly.by_weekday == std::vector<nocal::ByWeekday>{{0, Monday}, {0, Wednesday}, {0, Friday}}
&& weekly.until.has_value(),
"weekly RRULE fields differ");
const auto& monthly = *loaded.events[2].recurrence;
@@ -652,10 +652,61 @@ void test_supported_rdates(const std::filesystem::path& root) {
}
}
void test_ordinal_byday_round_trip(const std::filesystem::path& root) {
const auto path = root / "ordinal-byday.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\nUID:ordinal-weekly\r\nDTSTART:20260701T090000Z\r\n"
"DTEND:20260701T100000Z\r\n"
"RRULE:FREQ=MONTHLY;BYDAY=+1MO,-1FR\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite && loaded.warnings.empty(),
"ordinal BYDAY should be rewrite-safe");
require(loaded.events.size() == 1, "ordinal BYDAY fixture event count differs");
const auto& rule = *loaded.events[0].recurrence;
require(rule.frequency == nocal::RecurrenceFrequency::monthly,
"monthly frequency should be preserved");
require(rule.by_weekday.size() == 2, "two BYDAY entries expected");
require(rule.by_weekday[0].ordinal == 1 && rule.by_weekday[0].day == std::chrono::Monday,
"first BYDAY should be +1MO");
require(rule.by_weekday[1].ordinal == -1 && rule.by_weekday[1].day == std::chrono::Friday,
"second BYDAY should be -1FR");
const auto round_trip = root / "ordinal-byday-round-trip.ics";
nocal::storage::IcsStore::save(round_trip, loaded.events);
const std::string serialized = read_fixture(round_trip);
require(serialized.find("BYDAY=+1MO,-1FR") != std::string::npos,
"ordinal BYDAY should serialize as +1MO,-1FR");
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
require(reloaded.safe_to_rewrite && reloaded.warnings.empty(),
"ordinal BYDAY round-trip should be rewrite-safe");
require(reloaded.events[0].recurrence == loaded.events[0].recurrence,
"ordinal BYDAY semantic round trip differs");
}
void test_daily_with_byday_is_supported(const std::filesystem::path& root) {
const auto path = root / "daily-byday.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\nUID:daily-byday\r\nDTSTART:20260701T090000Z\r\n"
"DTEND:20260701T100000Z\r\n"
"RRULE:FREQ=DAILY;BYDAY=MO,WE,FR\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite && loaded.warnings.empty(),
"DAILY with BYDAY should now be rewrite-safe");
require(loaded.events.size() == 1, "daily-byday fixture event count differs");
const auto& rule = *loaded.events[0].recurrence;
require(rule.frequency == nocal::RecurrenceFrequency::daily,
"daily frequency should be preserved");
require(rule.by_weekday.size() == 3, "three BYDAY entries expected");
}
void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) {
const std::vector<std::string> unsupported_properties{
"RRULE:FREQ=WEEKLY;BYDAY=1MO",
"RRULE:FREQ=DAILY;BYDAY=MO",
"RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z",
"RRULE:FREQ=HOURLY",
"RRULE:FREQ=DAILY;WKST=MO",
@@ -779,7 +830,7 @@ void test_save_validation_preserves_existing_bytes(const std::filesystem::path&
nocal::Event invalid_rule = event_named("rule@example.test", "Invalid rule");
nocal::RecurrenceRule rule;
rule.frequency = nocal::RecurrenceFrequency::daily;
rule.by_weekday.push_back(Monday);
rule.interval = 0;
invalid_rule.recurrence = rule;
bool rule_failed = false;
try {
@@ -973,6 +1024,8 @@ int main() {
test_supported_recurrence_and_exdates(root);
test_supported_rdates(root);
test_unsupported_recurrence_and_zones_are_unsafe(root);
test_ordinal_byday_round_trip(root);
test_daily_with_byday_is_supported(root);
test_save_validation_preserves_existing_bytes(root);
test_vtimezone_custom_tzid_safe_and_round_trips(root);
test_vtimezone_multiple_tzids(root);