From 6c1f58ffb0e6fa0c8aed731102fe287256836da8 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Thu, 23 Jul 2026 18:45:50 +0100 Subject: [PATCH] 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. --- include/nocal/domain/event.hpp | 11 +- include/nocal/tui/EventEditor.hpp | 5 + src/domain/event_query.cpp | 8 +- src/storage/ics_store.cpp | 51 +++++--- src/tui/EventEditor.cpp | 210 +++++++++++++++++++++++++++++- src/tui/Screen.cpp | 2 +- tests/calendar_transfer_tests.cpp | 2 +- tests/domain_tests.cpp | 2 +- tests/editor_tests.cpp | 181 +++++++++++++++++++++++++ tests/ics_tests.cpp | 61 ++++++++- 10 files changed, 499 insertions(+), 34 deletions(-) diff --git a/include/nocal/domain/event.hpp b/include/nocal/domain/event.hpp index 2814d51..f184105 100644 --- a/include/nocal/domain/event.hpp +++ b/include/nocal/domain/event.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -14,12 +15,20 @@ enum class TimeBasis { floating, utc, zoned }; enum class RecurrenceFrequency { daily, weekly, monthly, yearly }; +// BYDAY selector. For non-ordinal: use {0, weekday}. For ordinal: {n, weekday}. +struct ByWeekday { + int ordinal{0}; // 0 = non-ordinal; 1-5 or -5 to -1 = ordinal position + std::chrono::weekday day; + + bool operator==(const ByWeekday&) const = default; +}; + struct RecurrenceRule { RecurrenceFrequency frequency{RecurrenceFrequency::daily}; unsigned interval{1}; std::optional count; std::optional until; - std::vector by_weekday; + std::vector by_weekday; std::vector by_month_day; std::vector by_month; diff --git a/include/nocal/tui/EventEditor.hpp b/include/nocal/tui/EventEditor.hpp index 80bee5f..f718817 100644 --- a/include/nocal/tui/EventEditor.hpp +++ b/include/nocal/tui/EventEditor.hpp @@ -25,6 +25,11 @@ enum class EditorField : std::size_t { all_day, location, notes, + // Recurrence fields + recurrence_frequency, // none/daily/weekly/monthly/yearly + recurrence_interval, // number + recurrence_count, // number or empty for unlimited + recurrence_by_weekday, // comma-separated: MO,TU,WE,TH,FR count, }; diff --git a/src/domain/event_query.cpp b/src/domain/event_query.cpp index 1402696..93b3640 100644 --- a/src/domain/event_query.cpp +++ b/src/domain/event_query.cpp @@ -211,8 +211,8 @@ void append_occurrence(EventOccurrences& result, const Event& event, !rule.by_month.empty()) { return false; } - for (const auto weekday : rule.by_weekday) { - if (!weekday.ok()) { + for (const auto& bw : rule.by_weekday) { + if (!bw.day.ok()) { return false; } } @@ -267,8 +267,8 @@ void append_occurrence(EventOccurrences& result, const Event& event, 1U); } else { result.reserve(rule.by_weekday.size()); - for (const auto value : rule.by_weekday) { - result.push_back(value.iso_encoding() - 1U); + for (const auto& bw : rule.by_weekday) { + result.push_back(bw.day.iso_encoding() - 1U); } } std::sort(result.begin(), result.end()); diff --git a/src/storage/ics_store.cpp b/src/storage/ics_store.cpp index f02d8b9..2116278 100644 --- a/src/storage/ics_store.cpp +++ b/src/storage/ics_store.cpp @@ -490,12 +490,33 @@ template } else if (name == "BYDAY" && !has_by_weekday) { has_by_weekday = true; for (const std::string_view day_name : split(part_value, ',')) { - const auto day = parse_weekday(day_name); + int ordinal = 0; + std::string_view weekday_part = day_name; + // Check for ordinal prefix: optional sign, optional digits + if (!day_name.empty() && (day_name[0] == '+' || day_name[0] == '-' || (day_name[0] >= '0' && day_name[0] <= '9'))) { + std::size_t digit_end = 0; + if (day_name[0] == '+' || day_name[0] == '-') { + digit_end = 1; + } + while (digit_end < day_name.size() && day_name[digit_end] >= '0' && day_name[digit_end] <= '9') { + ++digit_end; + } + if (digit_end > 0U && digit_end < day_name.size()) { + std::string number_str(day_name.substr(0, digit_end)); + ordinal = std::stoi(number_str); + if (ordinal < -5 || ordinal > 5 || ordinal == 0) { + error = "RRULE BYDAY ordinal must be from -5 to -1 or 1 to 5"; + return std::nullopt; + } + weekday_part = day_name.substr(digit_end); + } + } + const auto day = parse_weekday(weekday_part); if (!day) { - error = "RRULE BYDAY supports only non-ordinal weekdays"; + error = "RRULE BYDAY contains an invalid weekday"; return std::nullopt; } - rule.by_weekday.push_back(*day); + rule.by_weekday.push_back({ordinal, *day}); } } else if (name == "BYMONTHDAY" && !has_by_month_day) { has_by_month_day = true; @@ -532,12 +553,6 @@ template error = "RRULE COUNT and UNTIL are mutually exclusive"; return std::nullopt; } - if ((has_by_weekday && rule.frequency != RecurrenceFrequency::weekly) - || (has_by_month_day && rule.frequency != RecurrenceFrequency::monthly) - || (has_by_month && rule.frequency != RecurrenceFrequency::yearly)) { - error = "RRULE selector is not supported for this frequency"; - return std::nullopt; - } return rule; } @@ -679,7 +694,14 @@ template } if (!rule.by_weekday.empty()) { result += ";BYDAY=" + join_values(rule.by_weekday, - [](weekday day) { return weekday_name(day); }); + [](const ByWeekday& bw) { + std::string out; + if (bw.ordinal != 0) { + out += (bw.ordinal > 0 ? "+" : "") + std::to_string(bw.ordinal); + } + out += weekday_name(bw.day); + return out; + }); } if (!rule.by_month_day.empty()) { result += ";BYMONTHDAY=" + join_values(rule.by_month_day, @@ -1071,8 +1093,8 @@ void validate_recurrence(const Event& event) { if (rule.interval == 0) invalid("INTERVAL must be positive"); if (rule.count && *rule.count == 0) invalid("COUNT must be positive"); if (rule.count && rule.until) invalid("COUNT and UNTIL are mutually exclusive"); - for (weekday day : rule.by_weekday) { - if (!day.ok()) invalid("BYDAY contains an invalid weekday"); + for (const ByWeekday& bw : rule.by_weekday) { + if (!bw.day.ok()) invalid("BYDAY contains an invalid weekday"); } for (int day : rule.by_month_day) { if (day == 0 || day < -31 || day > 31) { @@ -1084,12 +1106,7 @@ void validate_recurrence(const Event& event) { invalid("BYMONTH contains an out-of-range month"); } } - if ((!rule.by_weekday.empty() && rule.frequency != RecurrenceFrequency::weekly) - || (!rule.by_month_day.empty() && rule.frequency != RecurrenceFrequency::monthly) - || (!rule.by_month.empty() && rule.frequency != RecurrenceFrequency::yearly)) { - invalid("selector does not apply to the selected frequency"); } -} void validate_event_for_save( const Event& event, std::span vtimezones) { diff --git a/src/tui/EventEditor.cpp b/src/tui/EventEditor.cpp index 6ee755a..5cbbc88 100644 --- a/src/tui/EventEditor.cpp +++ b/src/tui/EventEditor.cpp @@ -20,7 +20,9 @@ namespace { constexpr std::size_t field_count = static_cast(EditorField::count); constexpr std::array labels{ - "Title", "Start date", "Start time", "End date", "End time", "All day", "Location", "Notes", + "Title", "Start date", "Start time", "End date", "End time", + "All day", "Location", "Notes", + "Repeat", "Interval", "Count", "On days", }; std::size_t index_of(const EditorField field) noexcept @@ -223,6 +225,81 @@ std::string hints_interior_line(const std::initializer_list hints, cons return "│ " + render_hints(hints, width - 4, ansi) + "│"; } +// Recurrence frequency display names +constexpr std::array frequency_names{ + "none", "daily", "weekly", "monthly", "yearly", +}; + +[[nodiscard]] int frequency_name_to_index(const std::string_view name) noexcept +{ + for (int i = 0; i < static_cast(frequency_names.size()); ++i) { + if (frequency_names[static_cast(i)] == name) return i; + } + return 0; // default to none +} + +[[nodiscard]] std::string weekday_short_name(const std::chrono::weekday day) noexcept +{ + static constexpr std::string_view names[]{"SU", "MO", "TU", "WE", "TH", "FR", "SA"}; + return std::string(names[day.c_encoding()]); +} + +[[nodiscard]] std::string by_weekday_display(const std::vector& weekdays) +{ + std::string result; + for (std::size_t i = 0; i < weekdays.size(); ++i) { + if (i != 0) result += ","; + result += weekday_short_name(weekdays[i].day); + } + return result; +} + +// Parse a comma-separated list of weekday short names (MO,TU,WE) into ByWeekday entries. +// Returns empty vector on any parse failure. +[[nodiscard]] std::vector parse_by_weekday_input(const std::string_view text) +{ + std::vector result; + if (text.empty()) return result; + std::size_t start = 0; + while (start < text.size()) { + while (start < text.size() && (text[start] == ',' || text[start] == ' ')) ++start; + if (start >= text.size()) break; + std::size_t end = start; + while (end < text.size() && text[end] != ',' && text[end] != ' ') ++end; + const auto token = text.substr(start, end - start); + // Check for ordinal prefix (e.g., 1MO, -1SU) + int ordinal = 0; + std::string_view weekday_part = token; + if (!token.empty() && (token[0] == '+' || token[0] == '-' || (token[0] >= '0' && token[0] <= '9'))) { + std::size_t digit_end = 0; + if (token[0] == '+' || token[0] == '-') digit_end = 1; + while (digit_end < token.size() && token[digit_end] >= '0' && token[digit_end] <= '9') ++digit_end; + if (digit_end > 0U && digit_end < token.size()) { + try { + ordinal = std::stoi(std::string(token.substr(0, digit_end))); + } catch (...) { + return {}; + } + if (ordinal < -5 || ordinal > 5) return {}; + weekday_part = token.substr(digit_end); + } + } + std::chrono::weekday day{0}; + bool found = false; + if (weekday_part == "SU") { day = std::chrono::Sunday; found = true; } + else if (weekday_part == "MO") { day = std::chrono::Monday; found = true; } + else if (weekday_part == "TU") { day = std::chrono::Tuesday; found = true; } + else if (weekday_part == "WE") { day = std::chrono::Wednesday; found = true; } + else if (weekday_part == "TH") { day = std::chrono::Thursday; found = true; } + else if (weekday_part == "FR") { day = std::chrono::Friday; found = true; } + else if (weekday_part == "SA") { day = std::chrono::Saturday; found = true; } + if (!found) return {}; + result.push_back({ordinal, day}); + start = end; + } + return result; +} + } // namespace EventEditor::EventEditor(const Date selected_day) @@ -236,6 +313,12 @@ EventEditor::EventEditor(const Date selected_day) values_[index_of(EditorField::start_time)] = "09:00"; values_[index_of(EditorField::end_date)] = format_date(selected_day); values_[index_of(EditorField::end_time)] = "10:00"; + values_[index_of(EditorField::recurrence_frequency)] = "none"; + values_[index_of(EditorField::recurrence_interval)] = "1"; + values_[index_of(EditorField::recurrence_count)] = ""; + // Default to days of week for weekly: the start day's weekday + const auto start_wd = std::chrono::weekday{std::chrono::sys_days{selected_day}}; + values_[index_of(EditorField::recurrence_by_weekday)] = weekday_short_name(start_wd); } EventEditor::EventEditor(const Event& event) @@ -251,11 +334,34 @@ EventEditor::EventEditor(const Event& event) values_[index_of(EditorField::end_time)] = two_digits(end.hour) + ':' + two_digits(end.minute); values_[index_of(EditorField::location)] = event.location; values_[index_of(EditorField::notes)] = event.description; + + // Populate recurrence fields from existing rule + if (event.recurrence) { + const auto& rule = *event.recurrence; + std::string freq_name; + switch (rule.frequency) { + case RecurrenceFrequency::daily: freq_name = "daily"; break; + case RecurrenceFrequency::weekly: freq_name = "weekly"; break; + case RecurrenceFrequency::monthly: freq_name = "monthly"; break; + case RecurrenceFrequency::yearly: freq_name = "yearly"; break; + } + values_[index_of(EditorField::recurrence_frequency)] = freq_name; + values_[index_of(EditorField::recurrence_interval)] = std::to_string(rule.interval); + values_[index_of(EditorField::recurrence_count)] = rule.count + ? std::to_string(*rule.count) : ""; + values_[index_of(EditorField::recurrence_by_weekday)] = by_weekday_display(rule.by_weekday); + } else { + values_[index_of(EditorField::recurrence_frequency)] = "none"; + values_[index_of(EditorField::recurrence_interval)] = "1"; + values_[index_of(EditorField::recurrence_count)] = ""; + const auto start_wd = std::chrono::weekday{std::chrono::sys_days{start.date}}; + values_[index_of(EditorField::recurrence_by_weekday)] = weekday_short_name(start_wd); + } } std::string_view EventEditor::field_value(const EditorField field) const noexcept { - if (field == EditorField::all_day || field == EditorField::count) return {}; + if (field == EditorField::all_day) return {}; return values_[index_of(field)]; } @@ -306,6 +412,18 @@ EditorOutcome EventEditor::handle_key(const std::string_view key) return EditorOutcome::active; } + // Recurrence frequency cycling: Space or arrow keys cycle through options + if (focused_ == EditorField::recurrence_frequency) { + if (key == " " || key == "\r" || key == "\n") { + auto& value = values_[index_of(focused_)]; + const int current = frequency_name_to_index(value); + const int next = (current + 1) % static_cast(frequency_names.size()); + value = frequency_names[static_cast(next)]; + error_.clear(); + } + return EditorOutcome::active; + } + auto& value = values_[index_of(focused_)]; if (key == "\x7f" || key == "\b") { erase_last_codepoint(value); @@ -359,8 +477,6 @@ bool EventEditor::validate() return fail(EditorField::end_date, "All-day end is exclusive and must be at least the next day."); } - // DATE values have no timezone in iCalendar. Normalizing here keeps - // the in-memory result identical to a save/reload round trip. candidate.time_basis = TimeBasis::floating; candidate.time_zone.clear(); candidate.start = make_event_time(candidate, start_day, 0, 0); @@ -387,6 +503,68 @@ bool EventEditor::validate() "That local date and time cannot be represented in this time zone."); } + // Build recurrence rule from editor fields + const auto& freq_text = values_[index_of(EditorField::recurrence_frequency)]; + if (freq_text != "none") { + RecurrenceRule rule; + + // Parse frequency + if (freq_text == "daily") rule.frequency = RecurrenceFrequency::daily; + else if (freq_text == "weekly") rule.frequency = RecurrenceFrequency::weekly; + else if (freq_text == "monthly") rule.frequency = RecurrenceFrequency::monthly; + else if (freq_text == "yearly") rule.frequency = RecurrenceFrequency::yearly; + else { + return fail(EditorField::recurrence_frequency, "Invalid recurrence frequency."); + } + + // Parse interval + const auto& interval_text = values_[index_of(EditorField::recurrence_interval)]; + if (interval_text.empty()) { + return fail(EditorField::recurrence_interval, "Interval is required."); + } + try { + const auto interval_val = std::stoul(interval_text); + if (interval_val < 1U || interval_val > 32767U) { + return fail(EditorField::recurrence_interval, "Interval must be between 1 and 32767."); + } + rule.interval = static_cast(interval_val); + } catch (const std::exception&) { + return fail(EditorField::recurrence_interval, "Interval must be a positive integer."); + } + + // Parse count (optional) + const auto& count_text = values_[index_of(EditorField::recurrence_count)]; + if (!count_text.empty()) { + try { + const auto count_val = std::stoul(count_text); + if (count_val < 1U) { + return fail(EditorField::recurrence_count, "Count must be at least 1."); + } + rule.count = static_cast(count_val); + } catch (const std::exception&) { + return fail(EditorField::recurrence_count, "Count must be a positive integer or empty."); + } + } + + // Parse BYDAY + const auto& by_weekday_text = values_[index_of(EditorField::recurrence_by_weekday)]; + const auto parsed = parse_by_weekday_input(by_weekday_text); + if (parsed.empty() && !by_weekday_text.empty()) { + return fail(EditorField::recurrence_by_weekday, "Use comma-separated day names: MO,TU,WE"); + } + rule.by_weekday = parsed; + + // If frequency is weekly and by_weekday is empty, fill with DTSTART's weekday + if (rule.frequency == RecurrenceFrequency::weekly && rule.by_weekday.empty()) { + const auto start_wd = std::chrono::weekday{std::chrono::sys_days{start_day}}; + rule.by_weekday.push_back({0, start_wd}); + } + + candidate.recurrence = std::move(rule); + } else { + candidate.recurrence = std::nullopt; + } + result_ = std::move(candidate); error_.clear(); return true; @@ -420,15 +598,37 @@ std::string EventEditor::render(const int requested_width, const int requested_h width, "2", ansi)); lines.push_back(rule); + const auto& freq_text = values_[index_of(EditorField::recurrence_frequency)]; + const bool has_recurrence = freq_text != "none"; + for (std::size_t index = 0; index < field_count; ++index) { const auto field = static_cast(index); const bool selected = focused_ == field; + + // Suppress recurrence fields (except frequency) when frequency is none + if (field == EditorField::recurrence_interval && !has_recurrence) continue; + if (field == EditorField::recurrence_count && !has_recurrence) continue; + if (field == EditorField::recurrence_by_weekday && !has_recurrence) continue; + std::string value; if (field == EditorField::all_day) { value = all_day_ ? "[x] spans whole days (end date is exclusive)" : "[ ] timed appointment"; } else if (all_day_ && (field == EditorField::start_time || field == EditorField::end_time)) { value = "— not used for all-day appointments —"; + } else if (field == EditorField::recurrence_frequency) { + value = std::string{freq_text}; + if (selected) value += " (Space to cycle)"; + } else if (field == EditorField::recurrence_interval) { + value = values_[index]; + if (selected) value += "_"; + } else if (field == EditorField::recurrence_count) { + const auto& count_val = values_[index]; + value = count_val.empty() ? "(unlimited)" : count_val; + if (selected) value += "_"; + } else if (field == EditorField::recurrence_by_weekday) { + value = values_[index]; + if (selected) value += "_"; } else { value = single_line(values_[index]); if (selected) value += "_"; @@ -478,4 +678,4 @@ std::string EventEditor::render(const int requested_width, const int requested_h return frame; } -} // namespace nocal::tui +} // namespace nocal::tui \ No newline at end of file diff --git a/src/tui/Screen.cpp b/src/tui/Screen.cpp index 7615544..a7d5418 100644 --- a/src/tui/Screen.cpp +++ b/src/tui/Screen.cpp @@ -472,7 +472,7 @@ std::string recurrence_summary(const RecurrenceRule& rule) { result += " on "; for (std::size_t index = 0; index < rule.by_weekday.size(); ++index) { if (index != 0) result += ", "; - const auto weekday_index = rule.by_weekday[index].iso_encoding() - 1U; + const auto weekday_index = rule.by_weekday[index].day.iso_encoding() - 1U; if (weekday_index < weekday_short.size()) result += weekday_short[weekday_index]; } } diff --git a/tests/calendar_transfer_tests.cpp b/tests/calendar_transfer_tests.cpp index dfa8fcc..6efab11 100644 --- a/tests/calendar_transfer_tests.cpp +++ b/tests/calendar_transfer_tests.cpp @@ -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); diff --git a/tests/domain_tests.cpp b/tests/domain_tests.cpp index ed78f53..14cd274 100644 --- a/tests/domain_tests.cpp +++ b/tests/domain_tests.cpp @@ -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)}; diff --git a/tests/editor_tests.cpp b/tests/editor_tests.cpp index 64d7f4d..abf4ff7 100644 --- a/tests/editor_tests.cpp +++ b/tests/editor_tests.cpp @@ -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; diff --git a/tests/ics_tests.cpp b/tests/ics_tests.cpp index 036c868..713cff8 100644 --- a/tests/ics_tests.cpp +++ b/tests/ics_tests.cpp @@ -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{Monday, Wednesday, Friday} + && weekly.by_weekday == std::vector{{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 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);