feat: add calendar visibility picker

Derive session calendars from event metadata and apply one visibility contract to month rendering, appointment focus, and search without filtering persistence writes.

Add keyboard picker coverage, full-model save regression tests, documentation, warning-clean builds, sanitizer verification, and live PTY terminal restoration checks.
This commit is contained in:
2026-07-18 09:01:27 +01:00
parent 2774087d14
commit 88d370bf99
14 changed files with 416 additions and 12 deletions

View File

@@ -183,6 +183,34 @@ void test_event_search()
"event search does not locale-fold non-ASCII bytes");
}
void test_calendar_visibility()
{
const std::vector<nocal::Calendar> calendars{
{.id = "", .name = "Default", .color = {}, .visible = true,
.read_only = false},
{.id = "personal", .name = "Personal", .color = {}, .visible = true,
.read_only = false},
{.id = "work", .name = "Work", .color = {}, .visible = false,
.read_only = false},
};
nocal::Event default_event;
nocal::Event personal_event;
personal_event.calendar_id = "personal";
nocal::Event work_event;
work_event.calendar_id = "work";
nocal::Event new_event;
new_event.calendar_id = "new-calendar";
check(nocal::event_is_visible(default_event, calendars),
"an empty calendar ID uses the visible default calendar");
check(nocal::event_is_visible(personal_event, calendars),
"an explicitly visible calendar exposes its events");
check(!nocal::event_is_visible(work_event, calendars),
"a hidden calendar filters its events");
check(nocal::event_is_visible(new_event, calendars),
"unknown calendar metadata defaults visible instead of hiding data");
}
void test_daily_dst_recurrence()
{
using namespace std::chrono;
@@ -407,6 +435,7 @@ int main()
test_month_layout();
test_event_queries();
test_event_search();
test_calendar_visibility();
test_daily_dst_recurrence();
test_floating_dst_and_until();
test_weekly_count_and_exdates();

View File

@@ -956,6 +956,89 @@ void test_search_prompt_results_and_occurrence_jump()
check(!app.search_active(), "Esc/back closes empty search results");
}
void test_calendar_visibility_filters_without_partial_saves()
{
const auto day = nocal::today();
auto default_event = make_event("default", "D", day, 8, 0,
day, 9, 0);
auto personal_event = make_event("personal", "P", day, 9, 0,
day, 10, 0);
personal_event.calendar_id = "personal";
auto work_event = make_event("work", "W", day, 10, 0,
day, 11, 0);
work_event.calendar_id = "work";
std::vector<nocal::Event> events{default_event, personal_event, work_event};
int save_calls = 0;
std::vector<nocal::Event> saved;
nocal::tui::TuiApp app{
events,
[&](const std::span<const nocal::Event> values) {
++save_calls;
saved.assign(values.begin(), values.end());
},
-1,
-1,
};
check(app.calendars().size() == 3 && app.calendars()[0].id.empty() &&
app.calendars()[0].name == "Default" &&
app.calendars()[1].id == "personal" && app.calendars()[2].id == "work",
"controller derives a stable default-first calendar list from event metadata");
app.dispatch(nocal::tui::Action::calendars);
check(app.calendar_picker_active(), "c opens the calendar picker");
app.dispatch(nocal::tui::Action::down);
app.dispatch(nocal::tui::Action::down);
app.dispatch(nocal::tui::Action::select);
check(!app.calendars()[2].visible && save_calls == 0,
"toggling calendar visibility never crosses the persistence boundary");
app.dispatch(nocal::tui::Action::back);
auto render_state = app.state();
render_state.width = 120;
render_state.height = 36;
auto frame = nocal::tui::render_month(events, app.calendars(), render_state);
check(frame.find("08:00 D") != std::string::npos &&
frame.find("09:00 P") != std::string::npos &&
frame.find("10:00 W") == std::string::npos,
"month rendering excludes only events from hidden calendars");
app.handle_input("/");
app.handle_input("work");
app.handle_input("\r");
check(app.state().search_results.empty(),
"search shares the month view's calendar visibility contract");
app.dispatch(nocal::tui::Action::back);
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "default"), "focus starts on the first visible calendar event");
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "personal"), "focus advances to another visible calendar");
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "default"), "focus traversal skips hidden-calendar events");
app.dispatch(nocal::tui::Action::delete_event);
app.handle_input("y");
check(save_calls == 1 && saved.size() == 2 &&
std::any_of(saved.begin(), saved.end(), [](const nocal::Event& event) {
return event.uid == "work" && event.calendar_id == "work";
}),
"mutations save the full model, including events in hidden calendars");
app.dispatch(nocal::tui::Action::calendars);
app.dispatch(nocal::tui::Action::select);
check(app.calendars()[2].visible && save_calls == 1,
"calendar picker retains its selection and can reveal a calendar without saving");
app.dispatch(nocal::tui::Action::back);
app.dispatch(nocal::tui::Action::next_event);
app.dispatch(nocal::tui::Action::next_event);
check(focus_is(app, "work"), "revealed calendar events become focusable again");
app.dispatch(nocal::tui::Action::calendars);
app.dispatch(nocal::tui::Action::select);
check(!app.state().focused_event_id,
"hiding the focused event's calendar clears stale appointment focus");
check(save_calls == 1, "all visibility-only interactions remain unsaved session state");
}
} // namespace
int main()
@@ -983,6 +1066,7 @@ int main()
test_recurring_occurrence_navigation_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();
if (failures != 0) {
std::cerr << failures << " TUI focus test(s) failed\n";
return EXIT_FAILURE;

View File

@@ -97,6 +97,40 @@ void test_compact_and_input()
check(nocal::tui::decode_key("\x1b[6~") == nocal::tui::Action::next_month,
"PageDown is decoded");
check(nocal::tui::decode_key("k") == nocal::tui::Action::up, "Vim movement is decoded");
check(nocal::tui::decode_key("c") == nocal::tui::Action::calendars,
"c opens the calendar picker");
}
void test_calendar_picker_frame()
{
using namespace std::chrono;
auto state = nocal::tui::ScreenState{};
state.visible_month = year{2026} / July;
state.selected_day = year{2026} / July / day{17};
state.today = state.selected_day;
state.width = 52;
state.height = 10;
state.ansi = false;
state.show_calendar_picker = true;
state.calendar_index = 1;
const std::vector<nocal::Calendar> calendars{
{.id = "", .name = "Default", .color = {}, .visible = true,
.read_only = false},
{.id = "work", .name = "Work", .color = {}, .visible = false,
.read_only = false},
};
const auto frame = nocal::tui::render_month(
std::span<const nocal::Event>{}, calendars, state);
check(frame.find("CALENDARS") != std::string::npos &&
frame.find("1 of 2 visible") != std::string::npos &&
frame.find("[x] Default") != std::string::npos &&
frame.find("[ ] Work") != std::string::npos,
"calendar picker renders visibility and selection context");
check(std::count(frame.begin(), frame.end(), '\n') == 9,
"calendar picker fills the requested height");
check(frame.find("\x1b[") == std::string::npos,
"calendar picker remains readable without ANSI styling");
}
void test_navigation()
@@ -164,6 +198,7 @@ int main()
test_compact_and_input();
test_navigation();
test_search_frames();
test_calendar_picker_frame();
if (failures != 0) {
std::cerr << failures << " TUI test(s) failed\n";
return EXIT_FAILURE;