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:
2026-07-18 09:19:01 +01:00
parent 590db2f488
commit 8b6cf2e877
11 changed files with 609 additions and 16 deletions

View File

@@ -43,6 +43,12 @@ void test_full_month_render()
.search_result_index = 0,
.notification = {},
.notification_is_error = false,
.show_calendar_picker = false,
.calendar_index = 0,
.show_agenda = false,
.agenda_start_day = year{1970} / January / day{1},
.agenda_items = {},
.agenda_index = 0,
};
const std::vector<nocal::tui::CalendarItem> items{
{.id = "1", .title = "Design review", .day = year{2026} / July / day{17},
@@ -65,6 +71,8 @@ void test_full_month_render()
check(frame.find("Design") != std::string::npos, "appointment appears in its day cell");
check(frame.find("Release") != std::string::npos, "all-day appointment appears in its day cell");
check(frame.find("July 2026") != std::string::npos, "month title is rendered");
check(frame.find("g agenda") != std::string::npos,
"month footer advertises the agenda view");
check(std::count(frame.begin(), frame.end(), '\n') == 29,
"renderer fills the requested height deterministically");
check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled");
@@ -91,6 +99,12 @@ void test_compact_and_input()
.search_result_index = 0,
.notification = {},
.notification_is_error = false,
.show_calendar_picker = false,
.calendar_index = 0,
.show_agenda = false,
.agenda_start_day = year{1970} / January / day{1},
.agenda_items = {},
.agenda_index = 0,
};
const auto frame = nocal::tui::render_month(std::span<const nocal::tui::CalendarItem>{}, state);
check(frame.find("July 2026") != std::string::npos, "compact mode keeps month context");
@@ -190,6 +204,89 @@ void test_search_frames()
"slash opens appointment search");
}
void test_agenda_empty_frame()
{
using namespace std::chrono;
auto state = nocal::tui::ScreenState{};
state.width = 64;
state.height = 9;
state.ansi = false;
state.show_agenda = true;
state.agenda_start_day = year{2026} / July / day{6};
const auto frame = nocal::tui::render_month(
std::span<const nocal::tui::CalendarItem>{}, state);
check(frame.find("AGENDA") != std::string::npos &&
frame.find("2026-07-06 2026-08-16 (inclusive)") != std::string::npos,
"empty agenda renders its inclusive 42-day range");
check(frame.find("No appointments in this 6-week window.") != std::string::npos,
"empty agenda explains that its window has no appointments");
check(frame.find("g/Esc close") != std::string::npos,
"agenda footer explains how to close the view");
check(std::count(frame.begin(), frame.end(), '\n') == 8,
"empty agenda fills the requested height");
check(frame.find("\x1b[") == std::string::npos,
"agenda remains readable with ANSI disabled");
}
void test_agenda_selection_and_scrolling()
{
using namespace std::chrono;
auto state = nocal::tui::ScreenState{};
state.width = 88;
state.height = 7;
state.ansi = true;
state.show_agenda = true;
state.agenda_start_day = year{2026} / July / day{6};
state.agenda_items = {
{.focus_id = "late", .title = "Release", .day = year{2026} / July / day{12},
.time_of_day = hours{14} + minutes{30}, .all_day = false,
.location = "Studio", .calendar_id = "work", .recurring = true},
{.focus_id = "early", .title = "Holiday", .day = year{2026} / July / day{7},
.time_of_day = std::nullopt, .all_day = true,
.location = {}, .calendar_id = "home", .recurring = false},
{.focus_id = "middle", .title = "Planning", .day = year{2026} / July / day{9},
.time_of_day = hours{9}, .all_day = false,
.location = {}, .calendar_id = "work", .recurring = false},
{.focus_id = "selected", .title = "Retrospective", .day = year{2026} / July / day{13},
.time_of_day = hours{16}, .all_day = false,
.location = "Room 2", .calendar_id = "work", .recurring = false},
{.focus_id = "last", .title = "Dinner", .day = year{2026} / July / day{14},
.time_of_day = hours{19}, .all_day = false,
.location = {}, .calendar_id = "home", .recurring = false},
};
state.agenda_index = 3;
const auto frame = nocal::tui::render_month(
std::span<const nocal::tui::CalendarItem>{}, state);
check(frame.find("Holiday") == std::string::npos &&
frame.find("2026-07-12 14:30 local ↻ Release — Studio") != std::string::npos,
"agenda scrolls chronologically to keep a late selection visible");
check(frame.find(" 2026-07-13 16:00 local Retrospective — Room 2") != std::string::npos,
"agenda marks the selected row and includes optional location");
check(frame.find("\x1b[7;1m") != std::string::npos,
"ANSI agenda gives the selected row visible focus");
check(std::count(frame.begin(), frame.end(), '\n') == 6,
"scrolling agenda fills the requested height");
}
void test_agenda_input_and_search_precedence()
{
auto state = nocal::tui::ScreenState{};
state.width = 50;
state.height = 6;
state.ansi = false;
state.show_agenda = true;
state.search_prompt = true;
const auto frame = nocal::tui::render_month(
std::span<const nocal::tui::CalendarItem>{}, state);
check(frame.find("SEARCH APPOINTMENTS") != std::string::npos &&
frame.find("AGENDA") == std::string::npos,
"search rendering takes precedence over agenda rendering");
check(nocal::tui::decode_key("g") == nocal::tui::Action::agenda,
"g toggles the agenda view");
}
} // namespace
int main()
@@ -199,6 +296,9 @@ int main()
test_navigation();
test_search_frames();
test_calendar_picker_frame();
test_agenda_empty_frame();
test_agenda_selection_and_scrolling();
test_agenda_input_and_search_precedence();
if (failures != 0) {
std::cerr << failures << " TUI test(s) failed\n";
return EXIT_FAILURE;