feat: add bounded agenda view
Add a read-only 42-day occurrence agenda with keyboard navigation, six-week paging, exact return-to-grid focus, search and calendar overlays, and bounded recurrence expansion. Keep agenda state outside persistence, document month-only mutations, and cover empty windows, filtering, overlays, navigation, rendering, and terminal restoration.
This commit is contained in:
@@ -191,6 +191,7 @@ void test_key_decoding()
|
||||
check(decode_key("d") == Action::delete_event, "d decodes as delete appointment");
|
||||
check(decode_key("u") == Action::undo, "u decodes as undo");
|
||||
check(decode_key("\x12") == Action::redo, "Ctrl-R decodes as redo");
|
||||
check(decode_key("g") == Action::agenda, "g decodes as agenda");
|
||||
}
|
||||
|
||||
void test_crud_preconditions_and_add()
|
||||
@@ -1039,6 +1040,182 @@ void test_calendar_visibility_filters_without_partial_saves()
|
||||
check(save_calls == 1, "all visibility-only interactions remain unsaved session state");
|
||||
}
|
||||
|
||||
void test_agenda_window_order_visibility_and_bounds()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
const auto first_day = nocal::today();
|
||||
const auto second_day = nocal::from_sys_days(nocal::to_sys_days(first_day) + days{1});
|
||||
const auto after_window = nocal::from_sys_days(nocal::to_sys_days(first_day) + days{42});
|
||||
|
||||
auto series = make_event("agenda-series", "Daily standup", first_day, 9, 0,
|
||||
first_day, 9, 30);
|
||||
series.recurrence = nocal::RecurrenceRule{
|
||||
.frequency = nocal::RecurrenceFrequency::daily,
|
||||
.interval = 1,
|
||||
.count = 100,
|
||||
.until = std::nullopt,
|
||||
.by_weekday = {},
|
||||
.by_month_day = {},
|
||||
.by_month = {},
|
||||
};
|
||||
auto hidden = make_event("hidden-agenda", "Hidden work", first_day, 7, 0,
|
||||
first_day, 8, 0);
|
||||
hidden.calendar_id = "work";
|
||||
std::vector<nocal::Event> events{
|
||||
make_event("timed", "Morning review", first_day, 8, 0, first_day, 8, 30),
|
||||
make_event("all-day", "Release day", first_day, 0, 0, second_day, 0, 0, true),
|
||||
series,
|
||||
hidden,
|
||||
make_event("outside", "Outside window", after_window, 6, 0,
|
||||
after_window, 7, 0),
|
||||
};
|
||||
int save_calls = 0;
|
||||
nocal::tui::TuiApp app{
|
||||
events, [&](std::span<const nocal::Event>) { ++save_calls; }, -1, -1};
|
||||
|
||||
app.dispatch(nocal::tui::Action::calendars);
|
||||
app.dispatch(nocal::tui::Action::down);
|
||||
app.dispatch(nocal::tui::Action::select);
|
||||
app.dispatch(nocal::tui::Action::back);
|
||||
app.handle_input("g");
|
||||
|
||||
check(app.agenda_active() && app.state().agenda_start_day == first_day,
|
||||
"g opens a 42-day agenda anchored at the selected day");
|
||||
check(app.state().agenda_items.size() == 44,
|
||||
"agenda recurrence expansion is bounded to its 42-day window");
|
||||
if (app.state().agenda_items.size() == 44) {
|
||||
check(app.state().agenda_items[0].focus_id == "all-day" &&
|
||||
app.state().agenda_items[1].focus_id == "timed" &&
|
||||
app.state().agenda_items[2].focus_id.starts_with("agenda-series@occ:"),
|
||||
"agenda retains domain occurrence order with all-day items first");
|
||||
check(app.state().agenda_items.back().day ==
|
||||
nocal::from_sys_days(nocal::to_sys_days(first_day) + days{41}),
|
||||
"agenda includes the final day inside the half-open 42-day window");
|
||||
}
|
||||
check(std::none_of(app.state().agenda_items.begin(), app.state().agenda_items.end(),
|
||||
[&](const nocal::tui::AgendaItem& item) {
|
||||
return item.focus_id == "hidden-agenda" ||
|
||||
item.focus_id == "outside" ||
|
||||
nocal::to_sys_days(item.day) >= nocal::to_sys_days(after_window);
|
||||
}),
|
||||
"agenda excludes hidden calendars and occurrences outside its window");
|
||||
check(save_calls == 0, "opening and filtering agenda never saves events");
|
||||
}
|
||||
|
||||
void test_agenda_navigation_shift_choice_and_close()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
const auto first_day = nocal::today();
|
||||
auto series = make_event("choose-series", "Choose exact occurrence", first_day, 9, 0,
|
||||
first_day, 10, 0);
|
||||
series.recurrence = nocal::RecurrenceRule{
|
||||
.frequency = nocal::RecurrenceFrequency::daily,
|
||||
.interval = 1,
|
||||
.count = 100,
|
||||
.until = std::nullopt,
|
||||
.by_weekday = {},
|
||||
.by_month_day = {},
|
||||
.by_month = {},
|
||||
};
|
||||
std::vector<nocal::Event> events{series};
|
||||
int save_calls = 0;
|
||||
nocal::tui::TuiApp app{
|
||||
events, [&](std::span<const nocal::Event>) { ++save_calls; }, -1, -1};
|
||||
const auto month_day = app.state().selected_day;
|
||||
const auto month = app.state().visible_month;
|
||||
|
||||
app.dispatch(nocal::tui::Action::agenda);
|
||||
app.handle_input("k");
|
||||
check(app.state().agenda_index == 0,
|
||||
"agenda navigation clamps at the first item instead of wrapping");
|
||||
app.handle_input("j");
|
||||
check(app.state().agenda_index == 1, "j advances agenda selection");
|
||||
for (std::size_t index = 0; index < app.state().agenda_items.size() + 2; ++index) {
|
||||
app.dispatch(nocal::tui::Action::down);
|
||||
}
|
||||
check(app.state().agenda_index + 1 == app.state().agenda_items.size(),
|
||||
"agenda navigation clamps at the final item instead of wrapping");
|
||||
|
||||
app.handle_input("n");
|
||||
check(app.state().agenda_start_day ==
|
||||
nocal::from_sys_days(nocal::to_sys_days(first_day) + days{42}) &&
|
||||
app.state().agenda_index == 0 && !app.state().agenda_items.empty() &&
|
||||
app.state().agenda_items.front().day == app.state().agenda_start_day,
|
||||
"n shifts exactly 42 days and refreshes agenda from its new start");
|
||||
app.handle_input("\x1b[5~");
|
||||
check(app.state().agenda_start_day == first_day && app.state().agenda_index == 0,
|
||||
"Page Up shifts agenda exactly 42 days back and resets selection safely");
|
||||
|
||||
app.dispatch(nocal::tui::Action::down);
|
||||
const auto chosen = app.state().agenda_items[app.state().agenda_index];
|
||||
app.dispatch(nocal::tui::Action::select);
|
||||
check(!app.agenda_active() && app.state().selected_day == chosen.day &&
|
||||
app.state().visible_month == chosen.day.year() / chosen.day.month() &&
|
||||
app.state().focused_event_id == std::optional{chosen.focus_id},
|
||||
"Enter returns to month and focuses the exact recurring occurrence");
|
||||
|
||||
const auto chosen_day = app.state().selected_day;
|
||||
const auto chosen_month = app.state().visible_month;
|
||||
app.dispatch(nocal::tui::Action::agenda);
|
||||
app.dispatch(nocal::tui::Action::next_month);
|
||||
app.dispatch(nocal::tui::Action::agenda);
|
||||
check(!app.agenda_active() && app.state().selected_day == chosen_day &&
|
||||
app.state().visible_month == chosen_month,
|
||||
"g closes agenda without changing the prior month selection");
|
||||
app.dispatch(nocal::tui::Action::agenda);
|
||||
app.dispatch(nocal::tui::Action::previous_month);
|
||||
app.dispatch(nocal::tui::Action::back);
|
||||
check(!app.agenda_active() && app.state().selected_day == chosen_day &&
|
||||
app.state().visible_month == chosen_month,
|
||||
"Escape closes agenda without applying its shifted window to month selection");
|
||||
check(month_day == first_day && month == first_day.year() / first_day.month(),
|
||||
"agenda navigation fixture starts on the controller's month selection");
|
||||
check(save_calls == 0, "all agenda navigation and selection remains read-only");
|
||||
|
||||
std::vector<nocal::Event> empty_events;
|
||||
nocal::tui::TuiApp empty{empty_events, -1, -1};
|
||||
empty.dispatch(nocal::tui::Action::agenda);
|
||||
empty.dispatch(nocal::tui::Action::up);
|
||||
empty.dispatch(nocal::tui::Action::down);
|
||||
empty.dispatch(nocal::tui::Action::select);
|
||||
check(empty.agenda_active() && empty.state().agenda_items.empty() &&
|
||||
empty.state().agenda_index == 0,
|
||||
"empty agenda navigation and Enter are safe no-ops");
|
||||
}
|
||||
|
||||
void test_agenda_search_and_calendar_overlays()
|
||||
{
|
||||
const auto day = nocal::today();
|
||||
auto personal = make_event("agenda-personal", "Personal", day, 8, 0, day, 9, 0);
|
||||
personal.calendar_id = "personal";
|
||||
std::vector<nocal::Event> events{
|
||||
make_event("agenda-default", "Default", day, 7, 0, day, 8, 0), personal};
|
||||
int save_calls = 0;
|
||||
nocal::tui::TuiApp app{
|
||||
events, [&](std::span<const nocal::Event>) { ++save_calls; }, -1, -1};
|
||||
|
||||
app.dispatch(nocal::tui::Action::agenda);
|
||||
app.handle_input("/");
|
||||
check(app.agenda_active() && app.search_active(),
|
||||
"search prompt can overlay an active agenda");
|
||||
app.handle_input("\x1b");
|
||||
check(app.agenda_active() && !app.search_active(),
|
||||
"Escape closes search and returns to the underlying agenda");
|
||||
|
||||
app.handle_input("c");
|
||||
check(app.agenda_active() && app.calendar_picker_active(),
|
||||
"calendar picker can overlay an active agenda");
|
||||
app.dispatch(nocal::tui::Action::down);
|
||||
app.dispatch(nocal::tui::Action::select);
|
||||
check(!app.calendars()[1].visible && app.state().agenda_items.size() == 1 &&
|
||||
app.state().agenda_items.front().focus_id == "agenda-default",
|
||||
"hiding a calendar immediately refreshes the agenda beneath the picker");
|
||||
app.dispatch(nocal::tui::Action::back);
|
||||
check(app.agenda_active() && !app.calendar_picker_active(),
|
||||
"closing the picker returns to the agenda");
|
||||
check(save_calls == 0, "agenda overlays and visibility changes never save events");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
@@ -1067,6 +1244,9 @@ int main()
|
||||
test_recurrence_grid_expansion_and_source_zone_display();
|
||||
test_search_prompt_results_and_occurrence_jump();
|
||||
test_calendar_visibility_filters_without_partial_saves();
|
||||
test_agenda_window_order_visibility_and_bounds();
|
||||
test_agenda_navigation_shift_choice_and_close();
|
||||
test_agenda_search_and_calendar_overlays();
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " TUI focus test(s) failed\n";
|
||||
return EXIT_FAILURE;
|
||||
|
||||
Reference in New Issue
Block a user