feat: add recurrence and time zone support

This commit is contained in:
2026-07-17 21:52:02 +01:00
parent 22c6399056
commit c19098004e
18 changed files with 2321 additions and 132 deletions

View File

@@ -7,6 +7,7 @@
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
namespace {
@@ -215,6 +216,83 @@ void test_rendering()
check(exact_width, "wide Unicode input retains exact terminal-cell width");
}
nocal::TimePoint zoned_time(const std::string_view zone_name, const nocal::Date date,
const unsigned hour, const unsigned minute)
{
const auto* zone = std::chrono::locate_zone(zone_name);
const auto local = std::chrono::local_days{date} + std::chrono::hours{hour} +
std::chrono::minutes{minute};
return zone->to_sys(local, std::chrono::choose::earliest);
}
void test_zoned_edit_preserves_series_and_rejects_dst_gap()
{
using namespace std::chrono;
nocal::Event event;
event.uid = "zoned-series";
event.title = "New York standup";
event.time_basis = nocal::TimeBasis::zoned;
event.time_zone = "America/New_York";
event.start = zoned_time(event.time_zone, nocal::Date{2026y / March / 7d}, 2, 30);
event.end = zoned_time(event.time_zone, nocal::Date{2026y / March / 7d}, 3, 30);
event.recurrence = nocal::RecurrenceRule{
.frequency = nocal::RecurrenceFrequency::daily,
.interval = 1,
.count = 4,
.until = std::nullopt,
.by_weekday = {},
.by_month_day = {},
.by_month = {},
};
event.recurrence_exceptions.push_back(
zoned_time(event.time_zone, nocal::Date{2026y / March / 9d}, 2, 30));
nocal::tui::EventEditor editor{event};
check(editor.field_value(nocal::tui::EditorField::start_date) == "2026-03-07" &&
editor.field_value(nocal::tui::EditorField::start_time) == "02:30",
"a zoned editor displays civil values in the event source timezone");
const auto form = editor.render(80, 18, false);
check(form.find("America/New_York") != std::string::npos,
"a zoned editor explicitly labels its source timezone");
check(form.find("EDIT ENTIRE RECURRING SERIES") != std::string::npos,
"the editor clearly states that edits affect the recurring series");
replace_field(editor, "Renamed standup");
check(editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit,
"a valid zoned series edit submits");
check(editor.result().time_basis == nocal::TimeBasis::zoned &&
editor.result().time_zone == "America/New_York" &&
editor.result().recurrence == event.recurrence &&
editor.result().recurrence_exceptions == event.recurrence_exceptions,
"editing preserves time basis, TZID, recurrence, and exceptions");
nocal::tui::EventEditor series_kind_editor{event};
focus(series_kind_editor, nocal::tui::EditorField::all_day);
(void)series_kind_editor.handle_key(" ");
check(series_kind_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
series_kind_editor.focused_field() == nocal::tui::EditorField::all_day &&
series_kind_editor.error().find("recurring series") != std::string_view::npos,
"a recurring series rejects timed/all-day conversion to preserve its exceptions");
auto one_off = event;
one_off.recurrence.reset();
one_off.recurrence_exceptions.clear();
nocal::tui::EventEditor all_day_editor{one_off};
focus(all_day_editor, nocal::tui::EditorField::all_day);
(void)all_day_editor.handle_key(" ");
check(all_day_editor.handle_key("\x13") == nocal::tui::EditorOutcome::submit &&
all_day_editor.result().all_day &&
all_day_editor.result().time_basis == nocal::TimeBasis::floating &&
all_day_editor.result().time_zone.empty(),
"a one-off zoned event converted to all-day normalizes to floating DATE semantics");
nocal::tui::EventEditor gap_editor{event};
focus(gap_editor, nocal::tui::EditorField::start_date);
replace_field(gap_editor, "2026-03-08");
check(gap_editor.handle_key("\x13") == nocal::tui::EditorOutcome::active &&
gap_editor.error().find("cannot be represented") != std::string_view::npos,
"a nonexistent source-zone civil time in the DST gap is rejected");
}
} // namespace
int main()
@@ -225,6 +303,7 @@ int main()
test_all_day_exclusive_end();
test_unicode_backspace_navigation_and_cancel();
test_rendering();
test_zoned_edit_preserves_series_and_rejects_dst_gap();
if (failures != 0) {
std::cerr << failures << " editor test(s) failed\n";
return EXIT_FAILURE;