feat: support safe recurrence additions

Parse, persist, expand, and round-trip compatible RDATE values while preserving whole-series TUI behavior and conservative rewrite guards.

Verified: normal, warning-as-error, and ASan/UBSan builds pass all 9 suites; storage failure paths preserve calendar and backup bytes; live PTY restores terminal state.
This commit is contained in:
2026-07-18 10:14:53 +01:00
parent 989332ef9a
commit cfca6ab6d0
16 changed files with 498 additions and 69 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -39,6 +39,7 @@ struct Event {
TimeBasis time_basis{TimeBasis::utc};
std::string time_zone;
std::optional<RecurrenceRule> recurrence;
std::vector<TimePoint> recurrence_additions;
std::vector<TimePoint> 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

View File

@@ -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;
}

View File

@@ -86,6 +86,24 @@ template <class TimePointType>
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.
}

View File

@@ -51,6 +51,7 @@ struct PendingEvent {
std::optional<ParsedTime> start;
std::optional<ParsedTime> end;
std::vector<PendingProperty> recurrence_rules;
std::vector<PendingProperty> recurrence_dates;
std::vector<PendingProperty> exception_dates;
};
@@ -724,6 +725,15 @@ void serialize_calendar(std::ostream& output, std::span<const Event> 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<TimePoint> 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<TimePoint> 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});

View File

@@ -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<std::string> 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";

View File

@@ -802,7 +802,7 @@ std::string occurrence_focus_id(const std::span<const Event> 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<const Event> 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

View File

@@ -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<const Calendar>{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<std::ptrdiff_t>(*index));
if (!persist(std::span<const Event>{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(),

View File

@@ -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)); }},
};

View File

@@ -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<nocal::Event> 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<const nocal::Event>{&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<const nocal::Event>{&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<const nocal::Event>{&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<const nocal::Event>{&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) {

View File

@@ -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);

View File

@@ -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<nocal::TimePoint>{
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<nocal::TimePoint>{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<nocal::TimePoint>{
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<nocal::TimePoint>{
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<nocal::TimePoint>{
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<nocal::TimePoint>{
sys_days{year{2026}/July/25} + 9h,
sys_days{year{2026}/July/27} + 9h}
&& loaded.events[4].recurrence_exceptions
== std::vector<nocal::TimePoint>{
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<std::string> 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<InvalidRdate> 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<std::pair<std::string, std::string>> 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<nocal::Event>{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);

View File

@@ -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<nocal::Event> 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();