feat: add recurrence and time zone support
This commit is contained in:
@@ -2,9 +2,12 @@
|
||||
#include "nocal/tui/Screen.hpp"
|
||||
#include "nocal/tui/TuiApp.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
@@ -56,12 +59,20 @@ bool focus_is(const nocal::tui::TuiApp& app, const std::string_view uid)
|
||||
return app.state().focused_event_id && *app.state().focused_event_id == uid;
|
||||
}
|
||||
|
||||
bool focus_starts_with(const nocal::tui::TuiApp& app, const std::string_view prefix)
|
||||
{
|
||||
return app.state().focused_event_id && app.state().focused_event_id->starts_with(prefix);
|
||||
}
|
||||
|
||||
bool same_event(const nocal::Event& left, const nocal::Event& right)
|
||||
{
|
||||
return left.uid == right.uid && left.title == right.title && left.start == right.start &&
|
||||
left.end == right.end && left.all_day == right.all_day &&
|
||||
left.location == right.location && left.description == right.description &&
|
||||
left.calendar_id == right.calendar_id && left.color == right.color;
|
||||
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_exceptions == right.recurrence_exceptions;
|
||||
}
|
||||
|
||||
void replace_editor_title(nocal::tui::TuiApp& app, const std::string_view title)
|
||||
@@ -564,7 +575,11 @@ void test_focused_line_rendering()
|
||||
.description = {},
|
||||
.detail_title = "Plain item",
|
||||
.detail_all_day = false,
|
||||
.detail_start_time_of_day = hours{8}},
|
||||
.detail_start_time_of_day = hours{8},
|
||||
.segment = nocal::tui::ItemSegment::single,
|
||||
.recurring = false,
|
||||
.recurrence_summary = {},
|
||||
.source_time_zone = {}},
|
||||
{.id = "focused",
|
||||
.title = "Focused item",
|
||||
.day = day,
|
||||
@@ -577,7 +592,11 @@ void test_focused_line_rendering()
|
||||
.description = {},
|
||||
.detail_title = "Focused item",
|
||||
.detail_all_day = false,
|
||||
.detail_start_time_of_day = hours{9} + minutes{15}},
|
||||
.detail_start_time_of_day = hours{9} + minutes{15},
|
||||
.segment = nocal::tui::ItemSegment::single,
|
||||
.recurring = false,
|
||||
.recurrence_summary = {},
|
||||
.source_time_zone = {}},
|
||||
};
|
||||
|
||||
const auto frame = nocal::tui::render_month(items, state);
|
||||
@@ -698,6 +717,164 @@ void test_narrow_detail_wrapping_without_ansi()
|
||||
"narrow detail rendering emits no ANSI when styling is disabled");
|
||||
}
|
||||
|
||||
void test_multiday_segment_labels_and_series_messaging()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
const auto first = year{2026} / July / std::chrono::day{16};
|
||||
const auto middle = year{2026} / July / std::chrono::day{17};
|
||||
const auto last = year{2026} / July / std::chrono::day{18};
|
||||
std::vector<nocal::tui::CalendarItem> items{
|
||||
{.id = "series@occ:1", .title = "Migration", .day = first,
|
||||
.time_of_day = std::nullopt, .all_day = true,
|
||||
.end_time_of_day = std::nullopt, .start_day = first, .end_day = last,
|
||||
.location = {}, .description = {}, .detail_title = "Migration",
|
||||
.detail_all_day = true, .detail_start_time_of_day = std::nullopt,
|
||||
.segment = nocal::tui::ItemSegment::start,
|
||||
.recurring = true, .recurrence_summary = "Every week",
|
||||
.source_time_zone = "Europe/London"},
|
||||
{.id = "series@occ:1", .title = "Migration", .day = middle,
|
||||
.time_of_day = std::nullopt, .all_day = true,
|
||||
.end_time_of_day = std::nullopt, .start_day = first, .end_day = last,
|
||||
.location = {}, .description = {}, .detail_title = "Migration",
|
||||
.detail_all_day = true, .detail_start_time_of_day = std::nullopt,
|
||||
.segment = nocal::tui::ItemSegment::continuation,
|
||||
.recurring = true, .recurrence_summary = "Every week",
|
||||
.source_time_zone = "Europe/London"},
|
||||
{.id = "series@occ:1", .title = "Migration", .day = last,
|
||||
.time_of_day = std::nullopt, .all_day = true,
|
||||
.end_time_of_day = std::nullopt, .start_day = first, .end_day = last,
|
||||
.location = {}, .description = {}, .detail_title = "Migration",
|
||||
.detail_all_day = true, .detail_start_time_of_day = std::nullopt,
|
||||
.segment = nocal::tui::ItemSegment::end,
|
||||
.recurring = true, .recurrence_summary = "Every week",
|
||||
.source_time_zone = "Europe/London"},
|
||||
};
|
||||
auto state = screen_for(middle, 112, 38);
|
||||
state.focused_event_id = "series@occ:1";
|
||||
const auto month = nocal::tui::render_month(items, state);
|
||||
check(month.find("│ Migration") != std::string::npos,
|
||||
"a middle multi-day segment has a visible continuation marker without ANSI");
|
||||
|
||||
state.show_event_details = true;
|
||||
const auto reader = nocal::tui::render_month(items, state);
|
||||
check(reader.find("RECURRING APPOINTMENT") != std::string::npos &&
|
||||
reader.find("Every week") != std::string::npos &&
|
||||
reader.find("Europe/London") != std::string::npos &&
|
||||
reader.find("every occurrence") != std::string::npos,
|
||||
"the reader identifies recurrence, source timezone, and whole-series edits");
|
||||
|
||||
state.show_event_details = false;
|
||||
state.confirm_delete = true;
|
||||
const auto confirmation = nocal::tui::render_month(items, state);
|
||||
check(confirmation.find("entire recurring series") != std::string::npos,
|
||||
"recurring delete confirmation explicitly targets the whole series");
|
||||
}
|
||||
|
||||
void test_recurring_occurrence_navigation_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("duplicate", "Daily series", previous, 9, 0, previous, 10, 0);
|
||||
series.recurrence = nocal::RecurrenceRule{
|
||||
.frequency = nocal::RecurrenceFrequency::daily, .interval = 1, .count = 4,
|
||||
.until = std::nullopt, .by_weekday = {}, .by_month_day = {}, .by_month = {}};
|
||||
auto duplicate = make_event("duplicate", "Duplicate UID", selected, 11, 0, selected, 12, 0);
|
||||
std::vector<nocal::Event> events{series, duplicate};
|
||||
nocal::tui::TuiApp app{events, -1, -1};
|
||||
|
||||
app.dispatch(nocal::tui::Action::next_event);
|
||||
check(focus_starts_with(app, "@nocal:0@occ:"),
|
||||
"a recurring duplicate-UID occurrence has a stable source-and-start identity");
|
||||
const auto series_focus = app.state().focused_event_id;
|
||||
app.dispatch(nocal::tui::Action::next_event);
|
||||
check(focus_is(app, "@nocal:1"),
|
||||
"Tab navigates from a recurring instance to a duplicate-UID one-off event");
|
||||
app.dispatch(nocal::tui::Action::previous_event);
|
||||
check(app.state().focused_event_id == series_focus,
|
||||
"occurrence navigation returns to the exact recurring instance");
|
||||
|
||||
app.dispatch(nocal::tui::Action::edit_event);
|
||||
replace_editor_title(app, "Edited entire series");
|
||||
app.handle_input("\x13");
|
||||
check(events.front().title == "Edited entire series" && events.front().recurrence.has_value() &&
|
||||
app.state().notification == "Recurring series updated." &&
|
||||
focus_starts_with(app, "@nocal:0@occ:"),
|
||||
"editing an occurrence updates its source series and retains coherent focus");
|
||||
app.dispatch(nocal::tui::Action::undo);
|
||||
check(events.front().title == "Daily series" && events.front().recurrence == series.recurrence,
|
||||
"undo restores the complete recurring series metadata");
|
||||
app.dispatch(nocal::tui::Action::redo);
|
||||
check(events.front().title == "Edited entire series" && events.front().recurrence.has_value(),
|
||||
"redo reapplies the whole-series edit");
|
||||
|
||||
app.dispatch(nocal::tui::Action::delete_event);
|
||||
app.handle_input("y");
|
||||
check(events.size() == 1 && events.front().title == "Duplicate UID" &&
|
||||
app.state().notification == "Recurring series deleted.",
|
||||
"deleting a recurring occurrence removes only its entire source series");
|
||||
app.dispatch(nocal::tui::Action::undo);
|
||||
check(events.size() == 2 && events.front().recurrence.has_value(),
|
||||
"undo restores a deleted recurring series");
|
||||
}
|
||||
|
||||
void test_recurrence_grid_expansion_and_source_zone_display()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
const auto first = year{2026} / July / std::chrono::day{17};
|
||||
auto recurring = make_event("grid-series", "Grid repeat", first, 9, 0, first, 10, 0);
|
||||
recurring.recurrence = nocal::RecurrenceRule{
|
||||
.frequency = nocal::RecurrenceFrequency::daily, .interval = 1, .count = 3,
|
||||
.until = std::nullopt, .by_weekday = {}, .by_month_day = {}, .by_month = {}};
|
||||
std::vector<nocal::Event> events{recurring};
|
||||
auto state = screen_for(first, 140, 38);
|
||||
const auto frame = nocal::tui::render_month(events, state);
|
||||
std::size_t repeats = 0;
|
||||
for (std::size_t at = 0; (at = frame.find("Grid repeat", at)) != std::string::npos;
|
||||
at += std::string_view{"Grid repeat"}.size()) {
|
||||
++repeats;
|
||||
}
|
||||
check(repeats == 3, "the month adapter expands recurring instances only into visible days");
|
||||
auto compact_state = screen_for(first, 32, 12);
|
||||
compact_state.ansi = false;
|
||||
const auto compact = nocal::tui::render_month(events, compact_state);
|
||||
check(compact.find("\x1b[") == std::string::npos &&
|
||||
std::count(compact.begin(), compact.end(), '\n') == 11,
|
||||
"recurrence rendering preserves compact NO_COLOR frame geometry");
|
||||
|
||||
const char* prior_tz_value = std::getenv("TZ");
|
||||
const std::optional<std::string> prior_tz = prior_tz_value
|
||||
? std::optional{std::string{prior_tz_value}}
|
||||
: std::nullopt;
|
||||
(void)::setenv("TZ", "UTC0", 1);
|
||||
::tzset();
|
||||
const auto* zone = std::chrono::locate_zone("America/New_York");
|
||||
nocal::Event zoned;
|
||||
zoned.uid = "zoned-reader";
|
||||
zoned.title = "Zoned meeting";
|
||||
zoned.time_basis = nocal::TimeBasis::zoned;
|
||||
zoned.time_zone = "America/New_York";
|
||||
zoned.start = zone->to_sys(local_days{first} + hours{9}, choose::earliest);
|
||||
zoned.end = zone->to_sys(local_days{first} + hours{10}, choose::earliest);
|
||||
std::vector<nocal::Event> zoned_events{zoned};
|
||||
auto zoned_state = screen_for(first, 100, 28);
|
||||
const auto occurrences = nocal::occurrences_on_day(zoned_events, first);
|
||||
check(!occurrences.empty(), "the zoned test event occurs on the user's displayed day");
|
||||
if (!occurrences.empty()) {
|
||||
zoned_state.focused_event_id =
|
||||
nocal::tui::occurrence_focus_id(zoned_events, occurrences.front());
|
||||
zoned_state.show_event_details = true;
|
||||
const auto reader = nocal::tui::render_month(zoned_events, zoned_state);
|
||||
check(reader.find("America/New_York") != std::string::npos &&
|
||||
reader.find("13:00 – 14:00") != std::string::npos &&
|
||||
reader.find("09:00 – 10:00") == std::string::npos,
|
||||
"the reader names the source TZID while displaying instants in the user zone");
|
||||
}
|
||||
if (prior_tz) (void)::setenv("TZ", prior_tz->c_str(), 1);
|
||||
else (void)::unsetenv("TZ");
|
||||
::tzset();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
@@ -721,6 +898,9 @@ int main()
|
||||
test_timed_event_details();
|
||||
test_all_day_and_multiday_details();
|
||||
test_narrow_detail_wrapping_without_ansi();
|
||||
test_multiday_segment_labels_and_series_messaging();
|
||||
test_recurring_occurrence_navigation_and_whole_series_crud();
|
||||
test_recurrence_grid_expansion_and_source_zone_display();
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " TUI focus test(s) failed\n";
|
||||
return EXIT_FAILURE;
|
||||
|
||||
Reference in New Issue
Block a user