diff --git a/README.md b/README.md index 2bb6a19..f6e9b61 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,10 @@ source that fails event validation or that Nocal cannot safely round-trip. Nocal expands and safely round-trips a focused iCalendar recurrence subset: daily, weekly, monthly, and yearly rules with interval, count or until, common -weekly/monthly/yearly selectors, `EXDATE`, floating times, UTC, and system IANA -`TZID` names. Recurring instances are individually navigable; edit and delete +weekly/monthly/yearly selectors, compatible DATE or DATE-TIME `RDATE` additions, +`EXDATE`, floating times, UTC, and system IANA `TZID` names. Occurrences merge +`DTSTART`, rule expansion, and `RDATE`, remove duplicate starts, and then apply +`EXDATE`. Recurring instances are individually navigable; edit and delete clearly operate on the entire series. Search expands recurring matches within five years on either side of the @@ -88,8 +90,9 @@ agenda, appointment focus, and search. Visibility and agenda position are session-only presentation state; saves always retain events from hidden calendars. -Calendars containing features this version cannot preserve—such as `RDATE`, -detached recurrence overrides, ordinal `BYDAY`, embedded `VTIMEZONE` +Calendars containing features this version cannot preserve—such as `RDATE` +periods or values incompatible with the event's `DTSTART` time basis or `TZID`, +detached `RECURRENCE-ID` overrides, ordinal `BYDAY`, embedded `VTIMEZONE` definitions, alarms, or attendees—remain browsable but read-only. This guard prevents a local edit from silently discarding unknown data. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 45086ff..6c91cde 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,8 +36,11 @@ requested time window and return values that point back to their source series, preserving series-level storage and mutation semantics. The supported rule surface is deliberately bounded to daily/weekly/monthly/yearly frequency, interval, count or until, weekly `BYDAY`, monthly `BYMONTHDAY`, yearly -`BYMONTH`, and `EXDATE`. Invalid metadata retains at most the base event rather -than crashing a render. +`BYMONTH`, compatible DATE or DATE-TIME `RDATE`, and `EXDATE`. An occurrence set +merges `DTSTART`, rule expansion, and additional `RDATE` starts, deduplicates by +start, and then subtracts exclusions. Mutations continue to address the source +series as a whole. Invalid metadata retains at most the base event rather than +crashing a render. Search matching is a pure domain predicate over normalized event text; the TUI owns result presentation and occurrence identity. Recurring searches expand a @@ -107,12 +110,13 @@ combined with any of the other modes. The current writer deliberately refuses to mutate an existing file when the loader encounters information it cannot round-trip, including recurrence -overrides, `RDATE`, custom `VTIMEZONE` definitions, alarms, attendees, unknown -properties, or malformed components. Supported recurrence rules, exclusions, -and system IANA time-zone identifiers round-trip semantically. Browsing remains -available for unsupported files. This conservative boundary is more important -than partial editing because a successful-looking edit must not erase unrelated -calendar data. +overrides, `RDATE` periods or values incompatible with `DTSTART`'s time basis or +`TZID`, custom `VTIMEZONE` definitions, alarms, attendees, unknown properties, +or malformed components. Supported recurrence rules, compatible additional +starts, exclusions, and system IANA time-zone identifiers round-trip +semantically. Browsing remains available for unsupported files. This +conservative boundary is more important than partial editing because a +successful-looking edit must not erase unrelated calendar data. Provider synchronization will not write directly into this UI file. It will use a transaction-capable local cache and preserve remote ETags/sync tokens in a diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 8cbcc77..143ff88 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -89,22 +89,28 @@ read-only instead of being discarded. Saves reject external changes, retain a last-known-good backup, and feed bounded session undo/redo history. Supported recurring instances appear throughout the month, preserve civil time across daylight-saving changes, and expose their source zone and whole-series mutation -semantics in the reader. `/` searches occurrence-aware title, description, -location, and calendar-ID matches in a finite ten-year window centered on the -selected date. `c` toggles session-only calendar visibility consistently across -the grid, appointment focus, and search without filtering the model supplied to -persistence. `g` opens an occurrence-aware agenda at the selected month date; -navigation is bounded to consecutive 42-day windows and `Enter` returns to the -exact occurrence in the month grid. `Esc` or `g` closes the agenda without -changing the month selection unless an occurrence was chosen. Hidden calendars -remain filtered there, while calendar selection and search can overlay the -agenda. Agenda navigation never persists state, and add/edit/delete remain in -the month workflow. Week-start selection rotates only the month grid, header, -and the visible range used by its month queries; storage, agenda windows, and +semantics in the reader. A supported recurrence set combines `DTSTART`, rule +expansion, and compatible DATE or DATE-TIME `RDATE` starts, deduplicates starts, +and then removes `EXDATE` exclusions. `/` searches occurrence-aware title, +description, location, and calendar-ID matches in a finite ten-year window +centered on the selected date. `c` toggles session-only calendar visibility +consistently across the grid, appointment focus, and search without filtering +the model supplied to persistence. `g` opens an occurrence-aware agenda at the +selected month date; navigation is bounded to consecutive 42-day windows and +`Enter` returns to the exact occurrence in the month grid. `Esc` or `g` closes +the agenda without changing the month selection unless an occurrence was +chosen. Hidden calendars remain filtered there, while calendar selection and +search can overlay the agenda. Agenda navigation never persists state, and +add/edit/delete remain in the month workflow. Week-start selection rotates only +the month grid, header, and the visible range used by its month queries; +storage, agenda windows, and recurrence rules are unchanged. The default remains Monday for backward compatibility. The CLI accepts the exact lowercase values `monday`, `tuesday`, -`wednesday`, `thursday`, `friday`, `saturday`, and `sunday`. The next local-data -work adds advanced recurrence overrides and rule editing. +`wednesday`, `thursday`, `friday`, `saturday`, and `sunday`. `RDATE` periods, +values incompatible with the event's `DTSTART` time basis or `TZID`, detached +`RECURRENCE-ID` overrides, and embedded `VTIMEZONE` definitions remain +read-only. Remaining local-data work includes detached overrides and rule +editing. ## Local file interchange diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6be613c..9fd705d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,7 +8,8 @@ - Validated add/edit/delete forms with confirmation and failure rollback - Guarded atomic `.ics` persistence, advisory locking, and round-trip tests - Bounded session undo/redo, exact external-change detection, and backup recovery -- Windowed recurrence expansion, EXDATE, IANA TZID, and multi-day segment cues +- Windowed recurrence expansion, compatible RDATE, EXDATE, IANA TZID, and + multi-day segment cues - Occurrence-aware appointment search with exact result-to-grid navigation - Session calendar toggles shared by month rendering, focus, and search - Read-only 42-day agenda with bounded recurrence and exact return navigation @@ -19,7 +20,8 @@ ## 0.1 — complete local calendar -- Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support +- Advanced recurrence rule editing and detached RECURRENCE-ID overrides +- Embedded VTIMEZONE support - Screen-reader-friendly linear view and full Unicode display-width handling ## 0.2 — durable cache and provider boundary diff --git a/include/nocal/domain/event.hpp b/include/nocal/domain/event.hpp index c5b160d..2814d51 100644 --- a/include/nocal/domain/event.hpp +++ b/include/nocal/domain/event.hpp @@ -39,6 +39,7 @@ struct Event { TimeBasis time_basis{TimeBasis::utc}; std::string time_zone; std::optional recurrence; + std::vector recurrence_additions; std::vector recurrence_exceptions; }; @@ -55,4 +56,9 @@ struct Calendar { return event.end >= event.start; } +[[nodiscard]] constexpr bool is_recurring(const Event& event) noexcept +{ + return event.recurrence.has_value() || !event.recurrence_additions.empty(); +} + } // namespace nocal diff --git a/src/domain/calendar_transfer.cpp b/src/domain/calendar_transfer.cpp index b385c06..352da37 100644 --- a/src/domain/calendar_transfer.cpp +++ b/src/domain/calendar_transfer.cpp @@ -20,6 +20,7 @@ namespace { left.time_basis == right.time_basis && left.time_zone == right.time_zone && left.recurrence == right.recurrence && + left.recurrence_additions == right.recurrence_additions && left.recurrence_exceptions == right.recurrence_exceptions; } diff --git a/src/domain/event_query.cpp b/src/domain/event_query.cpp index 82b6bc2..1402696 100644 --- a/src/domain/event_query.cpp +++ b/src/domain/event_query.cpp @@ -86,6 +86,24 @@ template event.recurrence_exceptions.end(); } +void append_occurrence(EventOccurrences& result, const Event& event, + TimePoint start, TimePoint finish, std::size_t ordinal, + TimePoint begin, TimePoint end, + bool apply_exception = true) +{ + if ((apply_exception && is_exception(event, start)) || + !overlaps(start, finish, begin, end)) { + return; + } + const auto duplicate = std::find_if( + result.begin(), result.end(), [&event, start](const EventOccurrence& value) { + return value.source == &event && value.start == start; + }); + if (duplicate == result.end()) { + result.push_back({&event, start, finish, ordinal}); + } +} + [[nodiscard]] std::tm process_local_tm(std::time_t time) { std::tm result{}; @@ -334,10 +352,8 @@ void append_candidate(EventOccurrences& result, const Event& event, candidate_end = project_to_system(event, *civil_end, projection.zone); } - if (!is_exception(event, candidate_start) && - overlaps(candidate_start, candidate_end, begin, end)) { - result.push_back({&event, candidate_start, candidate_end, ordinal}); - } + append_occurrence(result, event, candidate_start, candidate_end, ordinal, + begin, end); } catch (const std::exception&) { // A bad zone, an out-of-range conversion, or a nonexistent civil time // invalidates only this generated occurrence. Queries remain noexcept @@ -345,6 +361,51 @@ void append_candidate(EventOccurrences& result, const Event& event, } } +void append_recurrence_additions(EventOccurrences& result, const Event& event, + const Projection& projection, TimePoint begin, + TimePoint end) +{ + const auto duration = + event.time_basis == TimeBasis::utc + ? checked_difference(event.end.time_since_epoch(), + event.start.time_since_epoch()) + : checked_difference(projection.end.time_since_epoch(), + projection.start.time_since_epoch()); + if (!duration) { + return; + } + + for (std::size_t index = 0; index < event.recurrence_additions.size(); + ++index) { + try { + const auto candidate_start = event.recurrence_additions[index]; + TimePoint candidate_end; + if (event.time_basis == TimeBasis::utc) { + const auto end_value = checked_add(candidate_start, *duration); + if (!end_value) { + continue; + } + candidate_end = *end_value; + } else { + const auto candidate_civil = + project_to_civil(event, candidate_start, projection.zone); + const auto civil_end = checked_add(candidate_civil, *duration); + if (!civil_end) { + continue; + } + candidate_end = + project_to_system(event, *civil_end, projection.zone); + } + const auto ordinal = index + 1U; + append_occurrence(result, event, candidate_start, candidate_end, + ordinal, begin, end); + } catch (const std::exception&) { + // An invalid zone, nonexistent civil end, or out-of-range value + // invalidates only this addition. + } + } +} + [[nodiscard]] std::chrono::sys_days recurrence_scan_end( const Event& event, const RecurrenceRule& rule, TimePoint query_end, const Projection& projection) @@ -564,12 +625,10 @@ void append_event_occurrences(EventOccurrences& result, const Event& event, return; } - const bool recurring = event.recurrence.has_value(); - if ((!recurring || !is_exception(event, event.start)) && - overlaps(event.start, event.end, begin, end)) { - result.push_back({&event, event.start, event.end, 0}); - } - if (!recurring || !valid_rule_shape(event, *event.recurrence)) { + const bool recurring = is_recurring(event); + append_occurrence(result, event, event.start, event.end, 0U, begin, end, + recurring); + if (!recurring) { return; } @@ -578,20 +637,27 @@ void append_event_occurrences(EventOccurrences& result, const Event& event, if (projection.end < projection.start) { return; } - switch (event.recurrence->frequency) { - case RecurrenceFrequency::daily: - expand_daily(result, event, *event.recurrence, projection, begin, end); - break; - case RecurrenceFrequency::weekly: - expand_weekly(result, event, *event.recurrence, projection, begin, end); - break; - case RecurrenceFrequency::monthly: - expand_monthly(result, event, *event.recurrence, projection, begin, end); - break; - case RecurrenceFrequency::yearly: - expand_yearly(result, event, *event.recurrence, projection, begin, end); - break; + if (event.recurrence && valid_rule_shape(event, *event.recurrence)) { + switch (event.recurrence->frequency) { + case RecurrenceFrequency::daily: + expand_daily(result, event, *event.recurrence, projection, begin, + end); + break; + case RecurrenceFrequency::weekly: + expand_weekly(result, event, *event.recurrence, projection, begin, + end); + break; + case RecurrenceFrequency::monthly: + expand_monthly(result, event, *event.recurrence, projection, + begin, end); + break; + case RecurrenceFrequency::yearly: + expand_yearly(result, event, *event.recurrence, projection, begin, + end); + break; + } } + append_recurrence_additions(result, event, projection, begin, end); } catch (const std::exception&) { // Invalid zones and out-of-range civil projections retain DTSTART only. } diff --git a/src/storage/ics_store.cpp b/src/storage/ics_store.cpp index e366c6d..9b8b91f 100644 --- a/src/storage/ics_store.cpp +++ b/src/storage/ics_store.cpp @@ -51,6 +51,7 @@ struct PendingEvent { std::optional start; std::optional end; std::vector recurrence_rules; + std::vector recurrence_dates; std::vector exception_dates; }; @@ -724,6 +725,15 @@ void serialize_calendar(std::ostream& output, std::span events) { if (event.recurrence) { write_folded(output, "RRULE:" + serialize_recurrence_rule(event, *event.recurrence)); } + if (!event.recurrence_additions.empty()) { + const std::string parameters = time_property_parameters(event); + const std::string values = join_values(event.recurrence_additions, + [&event](TimePoint addition) { + return event.all_day ? format_date(addition) + : format_event_time(event, addition); + }); + write_folded(output, "RDATE" + parameters + ":" + values); + } if (!event.recurrence_exceptions.empty()) { const std::string parameters = time_property_parameters(event); const std::string values = join_values(event.recurrence_exceptions, @@ -1056,9 +1066,17 @@ void validate_event_for_save(const Event& event) { throw std::invalid_argument( "cannot save event '" + event.uid + "' with an invalid interval"); } - if (!event.recurrence && !event.recurrence_exceptions.empty()) { + if (!is_recurring(event) && !event.recurrence_exceptions.empty()) { throw std::invalid_argument( - "cannot save event '" + event.uid + "' with EXDATE values but no recurrence rule"); + "cannot save event '" + event.uid + + "' with EXDATE values but no recurrence set"); + } + std::vector unique_additions = event.recurrence_additions; + std::sort(unique_additions.begin(), unique_additions.end()); + if (std::adjacent_find(unique_additions.begin(), unique_additions.end()) + != unique_additions.end()) { + throw std::invalid_argument( + "cannot save event '" + event.uid + "' with duplicate RDATE values"); } std::vector unique_exceptions = event.recurrence_exceptions; std::sort(unique_exceptions.begin(), unique_exceptions.end()); @@ -1221,6 +1239,29 @@ LoadResult IcsStore::load(const std::filesystem::path& path) { } } } + for (const PendingProperty& property : pending->recurrence_dates) { + for (const std::string_view addition_value : split(property.value, ',')) { + std::string warning; + const auto parsed = parse_time( + property.parameters, addition_value, warning); + if (!parsed || !same_time_kind(*pending->start, *parsed)) { + add_warning(result, property.line, parsed + ? "RDATE value type, time basis, or TZID does not match DTSTART" + : std::move(warning)); + mark_unsafe_to_rewrite(result); + continue; + } + if (!warning.empty()) { + add_warning(result, property.line, std::move(warning)); + mark_unsafe_to_rewrite(result); + } + if (std::find(event.recurrence_additions.begin(), + event.recurrence_additions.end(), parsed->value) + == event.recurrence_additions.end()) { + event.recurrence_additions.push_back(parsed->value); + } + } + } for (const PendingProperty& property : pending->exception_dates) { for (const std::string_view exception_value : split(property.value, ',')) { std::string warning; @@ -1244,9 +1285,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) { } } } - if (!event.recurrence && !event.recurrence_exceptions.empty()) { + if (!is_recurring(event) && !event.recurrence_exceptions.empty()) { add_warning(result, event_start_line, - "EXDATE without a supported RRULE cannot be edited safely"); + "EXDATE without a supported RRULE or RDATE cannot be edited safely"); mark_unsafe_to_rewrite(result); } result.events.push_back(std::move(event)); @@ -1275,7 +1316,7 @@ LoadResult IcsStore::load(const std::filesystem::path& path) { continue; } const bool time_property = name == "DTSTART" || name == "DTEND" - || name == "EXDATE"; + || name == "RDATE" || name == "EXDATE"; const bool recurrence_property = name == "RRULE"; const bool text_property = name == "UID" || name == "SUMMARY" || name == "LOCATION" || name == "DESCRIPTION" || name == "X-NOCAL-CALENDAR-ID" @@ -1305,6 +1346,9 @@ LoadResult IcsStore::load(const std::filesystem::path& path) { } else if (name == "RRULE") { pending->recurrence_rules.push_back(PendingProperty{ std::string(parameters), std::string(value), index + 1}); + } else if (name == "RDATE") { + pending->recurrence_dates.push_back(PendingProperty{ + std::string(parameters), std::string(value), index + 1}); } else if (name == "EXDATE") { pending->exception_dates.push_back(PendingProperty{ std::string(parameters), std::string(value), index + 1}); diff --git a/src/tui/EventEditor.cpp b/src/tui/EventEditor.cpp index 3a8c0be..33f5cdd 100644 --- a/src/tui/EventEditor.cpp +++ b/src/tui/EventEditor.cpp @@ -374,7 +374,7 @@ bool EventEditor::validate() Event candidate = original_; if (candidate.uid.empty()) candidate.uid = make_uid(); - if (candidate.recurrence && all_day_ != candidate.all_day) { + if (is_recurring(candidate) && all_day_ != candidate.all_day) { return fail(EditorField::all_day, "Timed/all-day conversion is unavailable for recurring series."); } @@ -435,7 +435,7 @@ std::string EventEditor::render(const int requested_width, const int requested_h std::vector lines; lines.reserve(16); lines.push_back(top); - const auto heading = editing_ && original_.recurrence + const auto heading = editing_ && is_recurring(original_) ? " NOCAL · EDIT ENTIRE RECURRING SERIES" : editing_ ? " NOCAL · EDIT APPOINTMENT" : " NOCAL · NEW APPOINTMENT"; diff --git a/src/tui/Screen.cpp b/src/tui/Screen.cpp index a8ac8a3..75e55ec 100644 --- a/src/tui/Screen.cpp +++ b/src/tui/Screen.cpp @@ -802,7 +802,7 @@ std::string occurrence_focus_id(const std::span events, const EventOccurrence& occurrence) { const auto index = event_source_index(events, occurrence.source); auto identity = source_focus_id(events, index); - if (occurrence.source->recurrence) { + if (is_recurring(*occurrence.source)) { const auto tick = occurrence.start.time_since_epoch().count(); identity += "@occ:" + std::to_string(tick); } @@ -1040,7 +1040,7 @@ std::string render_month(const std::span events, ? std::nullopt : std::optional{local_start.time_of_day}, .segment = segment, - .recurring = event.recurrence.has_value(), + .recurring = is_recurring(event), .recurrence_summary = event.recurrence ? recurrence_summary(*event.recurrence) : std::string{}, .source_time_zone = event.time_basis == TimeBasis::zoned diff --git a/src/tui/TuiApp.cpp b/src/tui/TuiApp.cpp index dee7384..86e2c1c 100644 --- a/src/tui/TuiApp.cpp +++ b/src/tui/TuiApp.cpp @@ -233,7 +233,7 @@ void TuiApp::refresh_agenda() { .all_day = occurrence.source->all_day || continuation, .location = occurrence.source->location, .calendar_id = occurrence.source->calendar_id, - .recurring = occurrence.source->recurrence.has_value(), + .recurring = is_recurring(*occurrence.source), }); } std::stable_sort(state_.agenda_items.begin(), state_.agenda_items.end(), @@ -463,7 +463,7 @@ void TuiApp::submit_editor() { std::span{calendars_})) { state_.focused_event_id.reset(); state_.show_event_details = false; - record_mutation(editing && events_[changed_index].recurrence + record_mutation(editing && is_recurring(events_[changed_index]) ? "edit recurring series" : editing ? "edit appointment" : "add appointment"); set_notification(std::move(success)); @@ -489,10 +489,10 @@ void TuiApp::submit_editor() { : std::optional{occurrence_focus_id(event_span, *first)}; } state_.show_event_details = false; - record_mutation(editing && events_[changed_index].recurrence + record_mutation(editing && is_recurring(events_[changed_index]) ? "edit recurring series" : editing ? "edit appointment" : "add appointment"); - if (editing && events_[changed_index].recurrence) { + if (editing && is_recurring(events_[changed_index])) { success = "Recurring series updated."; } set_notification(std::move(success)); @@ -509,7 +509,7 @@ void TuiApp::confirm_delete() { auto original = events_; const auto original_focus = state_.focused_event_id; - const bool recurring = events_[*index].recurrence.has_value(); + const bool recurring = is_recurring(events_[*index]); events_.erase(events_.begin() + static_cast(*index)); if (!persist(std::span{events_})) { events_.swap(original); @@ -574,7 +574,7 @@ void TuiApp::submit_search() { : std::optional{local_time_of_day(occurrence.start)}, .all_day = occurrence.source->all_day, .location = occurrence.source->location, - .recurring = occurrence.source->recurrence.has_value(), + .recurring = is_recurring(*occurrence.source), }); } std::stable_sort(state_.search_results.begin(), state_.search_results.end(), diff --git a/tests/calendar_transfer_tests.cpp b/tests/calendar_transfer_tests.cpp index 4bf7a1e..dfa8fcc 100644 --- a/tests/calendar_transfer_tests.cpp +++ b/tests/calendar_transfer_tests.cpp @@ -65,6 +65,7 @@ void check_invalid_argument(Function&& function, const std::string_view message) recurrence.by_month_day = {1, -1}; recurrence.by_month = {1, 7}; event.recurrence = std::move(recurrence); + event.recurrence_additions = {at(20)}; event.recurrence_exceptions = {at(16), at(18)}; return event; } @@ -79,6 +80,7 @@ void check_invalid_argument(Function&& function, const std::string_view message) left.time_basis == right.time_basis && left.time_zone == right.time_zone && left.recurrence == right.recurrence && + left.recurrence_additions == right.recurrence_additions && left.recurrence_exceptions == right.recurrence_exceptions; } @@ -163,6 +165,8 @@ void test_every_event_field_participates_in_collision_equality() {"time basis", [](auto& event) { event.time_basis = nocal::TimeBasis::utc; }}, {"time zone", [](auto& event) { event.time_zone = "America/New_York"; }}, {"recurrence", [](auto& event) { event.recurrence->interval += 1; }}, + {"recurrence additions", + [](auto& event) { event.recurrence_additions.push_back(at(19)); }}, {"recurrence exclusions", [](auto& event) { event.recurrence_exceptions.push_back(at(19)); }}, }; diff --git a/tests/domain_tests.cpp b/tests/domain_tests.cpp index 3e875ba..ed78f53 100644 --- a/tests/domain_tests.cpp +++ b/tests/domain_tests.cpp @@ -404,6 +404,90 @@ void test_weekly_count_and_exdates() "weekly BYDAY expansion is chronological across week boundaries"); } +void test_rdate_only_query_boundaries_and_duration() +{ + using namespace std::chrono; + const nocal::Date july_10{2026y / July / 10d}; + const nocal::Date july_11{2026y / July / 11d}; + nocal::Event added; + added.uid = "rdate-only"; + added.title = added.uid; + added.start = utc_time(2026y / July / 1d, 9); + added.end = added.start + minutes{90}; + added.recurrence_additions = {utc_time(july_10, 9), + utc_time(july_11, 9)}; + + check(nocal::is_recurring(added), + "an RDATE-only event is part of a recurrence set"); + const std::vector events{added}; + const auto occurrences = nocal::occurrences_overlapping( + events, utc_time(july_10, 10), utc_time(july_11, 9)); + check(occurrences.size() == 1 && + occurrences.front().start == utc_time(july_10, 9), + "RDATE overlap includes a carrying occurrence and excludes a start at the query end"); + check(occurrences.size() == 1 && + occurrences.front().end == utc_time(july_10, 10, 30), + "UTC RDATE occurrences preserve DTSTART's elapsed duration"); +} + +void test_rdate_merge_deduplication_and_exdates() +{ + using namespace std::chrono; + const nocal::Date july_1{2026y / July / 1d}; + nocal::RecurrenceRule daily; + daily.frequency = nocal::RecurrenceFrequency::daily; + daily.count = 3U; + auto merged = utc_recurring("rdate-merge", july_1, 9, july_1, 10, daily); + merged.recurrence_additions = { + merged.start, utc_time(2026y / July / 2d, 9), + utc_time(2026y / July / 4d, 9), utc_time(2026y / July / 4d, 9)}; + + const auto all = nocal::occurrences_overlapping( + std::span{&merged, 1}, utc_time(july_1, 0), + utc_time(2026y / July / 5d, 0)); + check(all.size() == 4, + "DTSTART, RRULE, and RDATE form a set with duplicate starts collapsed"); + check(all.size() == 4 && all[0].ordinal == 0U && all[1].ordinal == 1U && + all[2].ordinal == 2U && + all[3].start == utc_time(2026y / July / 4d, 9), + "RDATE merging preserves existing RRULE ordinals"); + + merged.recurrence_exceptions = {merged.start, + utc_time(2026y / July / 4d, 9)}; + const auto filtered = nocal::occurrences_overlapping( + std::span{&merged, 1}, utc_time(july_1, 0), + utc_time(2026y / July / 5d, 0)); + check(filtered.size() == 2 && + filtered[0].start == utc_time(2026y / July / 2d, 9) && + filtered[1].start == utc_time(2026y / July / 3d, 9), + "EXDATE subtracts both DTSTART and RDATE-only starts from the recurrence set"); +} + +void test_zoned_rdate_wall_duration() +{ + using namespace std::chrono; + nocal::Event added; + added.uid = "zoned-rdate"; + added.title = added.uid; + added.time_basis = nocal::TimeBasis::zoned; + added.time_zone = "Europe/London"; + added.start = zoned_time(added.time_zone, 2026y / March / 28d, 0, 30); + added.end = zoned_time(added.time_zone, 2026y / March / 28d, 2, 30); + added.recurrence_additions = { + zoned_time(added.time_zone, 2026y / March / 29d, 0, 30)}; + + const auto occurrences = nocal::occurrences_overlapping( + std::span{&added, 1}, + utc_time(2026y / March / 29d, 0), utc_time(2026y / March / 30d, 0)); + check(occurrences.size() == 1 && + occurrences[0].start == utc_time(2026y / March / 29d, 0, 30) && + occurrences[0].end == utc_time(2026y / March / 29d, 1, 30), + "zoned RDATE preserves a two-hour wall duration across the DST gap"); + check(occurrences.size() == 1 && + occurrences[0].end - occurrences[0].start == hours{1}, + "zoned RDATE projects its wall-clock end through the DST transition"); +} + void test_monthly_and_yearly_selectors() { using namespace std::chrono; @@ -482,6 +566,14 @@ void test_occurrence_overlap_sorting_and_invalid_rules() occurrences[3].source->uid == "later", "equal occurrence times sort by title and UID"); + invalid.recurrence_additions = {utc_time(day, 15)}; + const auto invalid_rule_addition = nocal::occurrences_overlapping( + std::span{&invalid, 1}, utc_time(day, 0), + utc_time(2026y / July / 18d, 0)); + check(invalid_rule_addition.size() == 2 && + invalid_rule_addition[1].start == utc_time(day, 15), + "a malformed RRULE does not suppress an independently valid RDATE"); + invalid.time_basis = nocal::TimeBasis::zoned; invalid.time_zone = "Not/A_Real_Zone"; invalid.recurrence->interval = 1U; @@ -504,6 +596,9 @@ int main() test_daily_dst_recurrence(); test_floating_dst_and_until(); test_weekly_count_and_exdates(); + test_rdate_only_query_boundaries_and_duration(); + test_rdate_merge_deduplication_and_exdates(); + test_zoned_rdate_wall_duration(); test_monthly_and_yearly_selectors(); test_occurrence_overlap_sorting_and_invalid_rules(); if (failures != 0) { diff --git a/tests/editor_tests.cpp b/tests/editor_tests.cpp index c827028..1b40576 100644 --- a/tests/editor_tests.cpp +++ b/tests/editor_tests.cpp @@ -246,6 +246,8 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap() }; event.recurrence_exceptions.push_back( zoned_time(event.time_zone, nocal::Date{2026y / March / 9d}, 2, 30)); + event.recurrence_additions.push_back( + zoned_time(event.time_zone, nocal::Date{2026y / March / 12d}, 2, 30)); nocal::tui::EventEditor editor{event}; check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-03-07" && @@ -262,8 +264,9 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap() check(editor.result().time_basis == nocal::TimeBasis::zoned && editor.result().time_zone == "America/New_York" && editor.result().recurrence == event.recurrence && + editor.result().recurrence_additions == event.recurrence_additions && editor.result().recurrence_exceptions == event.recurrence_exceptions, - "editing preserves time basis, TZID, recurrence, and exceptions"); + "editing preserves time basis, TZID, recurrence additions, and exceptions"); nocal::tui::EventEditor series_kind_editor{event}; focus(series_kind_editor, nocal::tui::EditorField::all_day); @@ -273,8 +276,21 @@ void test_zoned_edit_preserves_series_and_rejects_dst_gap() series_kind_editor.error().find("recurring series") != std::string_view::npos, "a recurring series rejects timed/all-day conversion to preserve its exceptions"); + auto rdate_only = event; + rdate_only.recurrence.reset(); + nocal::tui::EventEditor rdate_editor{rdate_only}; + check(rdate_editor.render(80, 18, false).find("EDIT ENTIRE RECURRING SERIES") + != std::string::npos, + "an RDATE-only event is presented as a recurring series"); + focus(rdate_editor, nocal::tui::EditorField::all_day); + (void)rdate_editor.handle_key(" "); + check(rdate_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active && + rdate_editor.error().find("recurring series") != std::string_view::npos, + "an RDATE-only series rejects timed/all-day conversion"); + auto one_off = event; one_off.recurrence.reset(); + one_off.recurrence_additions.clear(); one_off.recurrence_exceptions.clear(); nocal::tui::EventEditor all_day_editor{one_off}; focus(all_day_editor, nocal::tui::EditorField::all_day); diff --git a/tests/ics_tests.cpp b/tests/ics_tests.cpp index 8bcb63e..261e05c 100644 --- a/tests/ics_tests.cpp +++ b/tests/ics_tests.cpp @@ -473,6 +473,8 @@ void test_time_basis_round_trips_and_dst(const std::filesystem::path& root) { && reloaded.events[index].time_basis == loaded.events[index].time_basis && reloaded.events[index].time_zone == loaded.events[index].time_zone && reloaded.events[index].recurrence == loaded.events[index].recurrence + && reloaded.events[index].recurrence_additions + == loaded.events[index].recurrence_additions && reloaded.events[index].recurrence_exceptions == loaded.events[index].recurrence_exceptions, "time-basis semantic round trip differs"); @@ -541,12 +543,115 @@ void test_supported_recurrence_and_exdates(const std::filesystem::path& root) { "recurrence semantic round-trip event count differs"); for (std::size_t index = 0; index < loaded.events.size(); ++index) { require(reloaded.events[index].recurrence == loaded.events[index].recurrence + && reloaded.events[index].recurrence_additions + == loaded.events[index].recurrence_additions && reloaded.events[index].recurrence_exceptions == loaded.events[index].recurrence_exceptions, "recurrence semantic round trip differs"); } } +void test_supported_rdates(const std::filesystem::path& root) { + const auto path = root / "rdates.ics"; + write_fixture(path, + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" + "BEGIN:VEVENT\r\nUID:utc-rdate\r\nSUMMARY:UTC additions\r\n" + "DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n" + "RDATE:20260718T090000Z,20260720T090000Z\r\n" + "RDATE:20260722T090000Z\r\nEXDATE:20260720T090000Z\r\nEND:VEVENT\r\n" + "BEGIN:VEVENT\r\nUID:floating-rdate\r\nSUMMARY:Floating additions\r\n" + "DTSTART:20260717T101500\r\nDTEND:20260717T111500\r\n" + "RDATE:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n" + "BEGIN:VEVENT\r\nUID:zoned-rdate\r\nSUMMARY:Zoned additions\r\n" + "DTSTART;TZID=Europe/London:20260717T101500\r\n" + "DTEND;TZID=Europe/London:20260717T111500\r\n" + "RDATE;TZID=Europe/London:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n" + "BEGIN:VEVENT\r\nUID:all-day-rdate\r\nSUMMARY:All-day additions\r\n" + "DTSTART;VALUE=DATE:20260717\r\nDTEND;VALUE=DATE:20260718\r\n" + "RDATE;VALUE=DATE:20260719,20260721\r\nEND:VEVENT\r\n" + "BEGIN:VEVENT\r\nUID:mixed-rdate\r\nSUMMARY:Rule and additions\r\n" + "DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n" + "RRULE:FREQ=DAILY;COUNT=3\r\nRDATE:20260725T090000Z,20260727T090000Z\r\n" + "EXDATE:20260718T090000Z,20260725T090000Z\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(), + "supported RDATE values were not rewrite-safe"); + require(loaded.events.size() == 5, "RDATE fixture event count differs"); + require(!loaded.events[0].recurrence.has_value(), + "RDATE-only event acquired an RRULE"); + require(loaded.events[0].recurrence_additions + == std::vector{ + sys_days{year{2026}/July/18} + 9h, + sys_days{year{2026}/July/20} + 9h, + sys_days{year{2026}/July/22} + 9h}, + "multiple or comma-separated UTC RDATE values were not collected"); + require(loaded.events[0].recurrence_exceptions + == std::vector{sys_days{year{2026}/July/20} + 9h}, + "RDATE-only EXDATE value was not retained"); + require(loaded.events[1].time_basis == nocal::TimeBasis::floating + && loaded.events[1].recurrence_additions + == std::vector{ + sys_days{year{2026}/July/18} + 10h + 15min, + sys_days{year{2026}/July/19} + 10h + 15min}, + "floating RDATE values did not preserve their time basis"); + require(loaded.events[2].time_basis == nocal::TimeBasis::zoned + && loaded.events[2].time_zone == "Europe/London" + && loaded.events[2].recurrence_additions + == std::vector{ + sys_days{year{2026}/July/18} + 9h + 15min, + sys_days{year{2026}/July/19} + 9h + 15min}, + "zoned RDATE values were not converted with DTSTART's TZID"); + require(loaded.events[3].all_day + && loaded.events[3].recurrence_additions + == std::vector{ + sys_days{year{2026}/July/19}, sys_days{year{2026}/July/21}}, + "all-day RDATE values were not parsed as DATE values"); + require(loaded.events[4].recurrence.has_value() + && loaded.events[4].recurrence_additions + == std::vector{ + sys_days{year{2026}/July/25} + 9h, + sys_days{year{2026}/July/27} + 9h} + && loaded.events[4].recurrence_exceptions + == std::vector{ + sys_days{year{2026}/July/18} + 9h, + sys_days{year{2026}/July/25} + 9h}, + "mixed RRULE, RDATE, and EXDATE values differ"); + + const auto round_trip = root / "rdates-round-trip.ics"; + nocal::storage::IcsStore::save(round_trip, loaded.events); + const std::string serialized = read_fixture(round_trip); + require(serialized.find( + "RDATE:20260718T090000Z,20260720T090000Z,20260722T090000Z") + != std::string::npos, + "UTC RDATE values were not grouped on serialization"); + require(serialized.find("RDATE:20260718T101500,20260719T101500") + != std::string::npos, + "floating RDATE values were not serialized without a suffix"); + require(serialized.find( + "RDATE;TZID=Europe/London:20260718T101500,20260719T101500") + != std::string::npos, + "zoned RDATE values were not serialized with TZID"); + require(serialized.find("RDATE;VALUE=DATE:20260719,20260721") + != std::string::npos, + "all-day RDATE values were not serialized with VALUE=DATE"); + + const auto reloaded = nocal::storage::IcsStore::load(round_trip); + require(reloaded.safe_to_rewrite && reloaded.warnings.empty(), + "serialized RDATE calendar did not reload safely"); + require(reloaded.events.size() == loaded.events.size(), + "RDATE semantic round-trip event count differs"); + for (std::size_t index = 0; index < loaded.events.size(); ++index) { + require(reloaded.events[index].recurrence == loaded.events[index].recurrence + && reloaded.events[index].recurrence_additions + == loaded.events[index].recurrence_additions + && reloaded.events[index].recurrence_exceptions + == loaded.events[index].recurrence_exceptions, + "RDATE semantic round trip differs"); + } +} + void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) { const std::vector unsupported_properties{ "RRULE:FREQ=WEEKLY;BYDAY=1MO", @@ -554,7 +659,7 @@ void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::pat "RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z", "RRULE:FREQ=HOURLY", "RRULE:FREQ=DAILY;WKST=MO", - "RDATE:20260718T090000Z", + "RDATE;VALUE=PERIOD:20260718T090000Z/PT1H", "RECURRENCE-ID:20260718T090000Z"}; for (std::size_t index = 0; index < unsupported_properties.size(); ++index) { const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics"); @@ -595,6 +700,37 @@ void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::pat require(!nocal::storage::IcsStore::load(custom_zone).safe_to_rewrite, "VTIMEZONE definition was considered rewrite-safe"); + struct InvalidRdate { + std::string start; + std::string end; + std::string property; + }; + const std::vector invalid_rdates{ + {"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z", + "RDATE:20260718T090000"}, + {"DTSTART;TZID=Europe/London:20260717T090000", + "DTEND;TZID=Europe/London:20260717T100000", + "RDATE;TZID=Europe/Paris:20260718T090000"}, + {"DTSTART;VALUE=DATE:20260717", "DTEND;VALUE=DATE:20260718", + "RDATE:20260719T090000Z"}, + {"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z", + "RDATE;TZID=Custom/Mars:20260718T090000"}, + {"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z", + "RDATE:not-a-date"}}; + for (std::size_t index = 0; index < invalid_rdates.size(); ++index) { + const auto path = root / ("invalid-rdate-" + std::to_string(index) + ".ics"); + const auto& fixture = invalid_rdates[index]; + write_fixture(path, + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:invalid-rdate\r\n" + + fixture.start + "\r\n" + fixture.end + "\r\n" + fixture.property + + "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"); + const auto loaded = nocal::storage::IcsStore::load(path); + require(!loaded.safe_to_rewrite && !loaded.warnings.empty(), + "invalid RDATE was considered rewrite-safe"); + require(loaded.events.size() == 1 && loaded.events[0].recurrence_additions.empty(), + "invalid RDATE prevented base-event browsing or became an occurrence"); + } + const std::vector> mismatched_ends{ {"DTSTART:20260717T090000Z", "DTEND:20260717T100000"}, {"DTSTART;TZID=Europe/London:20260717T090000", @@ -660,7 +796,20 @@ void test_save_validation_preserves_existing_bytes(const std::filesystem::path& } require(exdate_failed && read_fixture(path) == original && read_fixture(backup) == original_backup, - "EXDATE-without-RRULE validation changed calendar or backup bytes"); + "EXDATE-without-RRULE-or-RDATE validation changed calendar or backup bytes"); + + nocal::Event duplicate_rdate = event_named("rdate@example.test", "Duplicate RDATE"); + duplicate_rdate.recurrence_additions = { + duplicate_rdate.start + 24h, duplicate_rdate.start + 24h}; + bool rdate_failed = false; + try { + nocal::storage::IcsStore::save(path, std::vector{duplicate_rdate}); + } catch (const std::invalid_argument&) { + rdate_failed = true; + } + require(rdate_failed && read_fixture(path) == original + && read_fixture(backup) == original_backup, + "duplicate RDATE validation changed calendar or backup bytes"); require_no_temporary_files(path); } @@ -691,6 +840,7 @@ int main() { test_unsupported_data_is_not_rewrite_safe(root); test_time_basis_round_trips_and_dst(root); test_supported_recurrence_and_exdates(root); + test_supported_rdates(root); test_unsupported_recurrence_and_zones_are_unsafe(root); test_save_validation_preserves_existing_bytes(root); std::filesystem::remove_all(root, ignored); diff --git a/tests/tui_focus_tests.cpp b/tests/tui_focus_tests.cpp index dbb89a8..9488a47 100644 --- a/tests/tui_focus_tests.cpp +++ b/tests/tui_focus_tests.cpp @@ -72,6 +72,7 @@ bool same_event(const nocal::Event& left, const nocal::Event& right) left.calendar_id == right.calendar_id && left.color == right.color && left.time_basis == right.time_basis && left.time_zone == right.time_zone && left.recurrence == right.recurrence && + left.recurrence_additions == right.recurrence_additions && left.recurrence_exceptions == right.recurrence_exceptions; } @@ -819,6 +820,36 @@ void test_recurring_occurrence_navigation_and_whole_series_crud() "undo restores a deleted recurring series"); } +void test_rdate_only_occurrence_identity_and_whole_series_crud() +{ + using namespace std::chrono; + const auto selected = nocal::today(); + const auto previous = nocal::from_sys_days(nocal::to_sys_days(selected) - days{1}); + auto series = make_event("rdate-only", "Extra date", previous, 9, 0, + previous, 10, 0); + series.recurrence_additions = { + nocal::make_local_time(selected, 9, 0), + }; + std::vector events{series}; + nocal::tui::TuiApp app{events, -1, -1}; + + app.dispatch(nocal::tui::Action::next_event); + check(focus_starts_with(app, "rdate-only@occ:"), + "an RDATE-only occurrence has a source-and-start focus identity"); + app.dispatch(nocal::tui::Action::edit_event); + replace_editor_title(app, "Edited extra-date series"); + app.handle_input("\x13"); + check(events.front().title == "Edited extra-date series" && + events.front().recurrence_additions == series.recurrence_additions && + app.state().notification == "Recurring series updated.", + "editing an RDATE-only occurrence updates and preserves the whole series"); + + app.dispatch(nocal::tui::Action::delete_event); + app.handle_input("y"); + check(events.empty() && app.state().notification == "Recurring series deleted.", + "deleting an RDATE-only occurrence removes its entire source series"); +} + void test_recurrence_grid_expansion_and_source_zone_display() { using namespace std::chrono; @@ -1241,6 +1272,7 @@ int main() test_narrow_detail_wrapping_without_ansi(); test_multiday_segment_labels_and_series_messaging(); test_recurring_occurrence_navigation_and_whole_series_crud(); + test_rdate_only_occurrence_identity_and_whole_series_crud(); test_recurrence_grid_expansion_and_source_zone_display(); test_search_prompt_results_and_occurrence_jump(); test_calendar_visibility_filters_without_partial_saves();