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

@@ -169,6 +169,111 @@ void TuiApp::toggle_selected_calendar() {
state_.focused_event_id.reset();
state_.show_event_details = false;
}
if (state_.show_agenda) refresh_agenda();
}
void TuiApp::begin_agenda() {
state_.show_agenda = true;
state_.agenda_start_day = state_.selected_day;
state_.agenda_index = 0;
state_.agenda_items.clear();
state_.show_event_details = false;
state_.show_help = false;
state_.confirm_delete = false;
state_.search_prompt = false;
state_.show_search_results = false;
state_.show_calendar_picker = false;
refresh_agenda();
}
void TuiApp::close_agenda() {
state_.show_agenda = false;
}
void TuiApp::refresh_agenda() {
if (!state_.agenda_start_day.ok()) state_.agenda_start_day = state_.selected_day;
std::optional<std::string> selected_identity;
if (state_.agenda_index < state_.agenda_items.size()) {
selected_identity = state_.agenda_items[state_.agenda_index].focus_id;
}
state_.agenda_items.clear();
const auto event_span = std::span<const Event>{events_};
const auto calendar_span = std::span<const Calendar>{calendars_};
const auto first_day = sys_days{state_.agenda_start_day};
constexpr auto window = days{42};
const auto end_day = first_day + window;
for (const auto& occurrence : occurrences_overlapping(
event_span, local_day_start(year_month_day{first_day}),
local_day_start(year_month_day{end_day}))) {
if (occurrence.source == nullptr ||
!event_is_visible(*occurrence.source, calendar_span)) {
continue;
}
const auto occurrence_day = local_date(occurrence.start);
const auto display_day = std::max(occurrence_day, state_.agenda_start_day);
const bool continuation = display_day != occurrence_day;
state_.agenda_items.push_back(AgendaItem{
.focus_id = occurrence_focus_id(event_span, occurrence),
.title = occurrence.source->title,
.day = display_day,
.time_of_day = occurrence.source->all_day || continuation
? std::nullopt
: std::optional{local_time_of_day(occurrence.start)},
.all_day = occurrence.source->all_day || continuation,
.location = occurrence.source->location,
.calendar_id = occurrence.source->calendar_id,
.recurring = occurrence.source->recurrence.has_value(),
});
}
std::stable_sort(state_.agenda_items.begin(), state_.agenda_items.end(),
[](const AgendaItem& left, const AgendaItem& right) {
const auto left_day = sys_days{left.day};
const auto right_day = sys_days{right.day};
if (left_day != right_day) return left_day < right_day;
if (left.all_day != right.all_day) return left.all_day;
if (left.time_of_day != right.time_of_day) {
return left.time_of_day.value_or(minutes{-1}) <
right.time_of_day.value_or(minutes{-1});
}
if (left.title != right.title) return left.title < right.title;
return left.focus_id < right.focus_id;
});
if (state_.agenda_items.empty()) {
state_.agenda_index = 0;
return;
}
if (selected_identity) {
const auto selected = std::find_if(
state_.agenda_items.begin(), state_.agenda_items.end(),
[&](const AgendaItem& item) { return item.focus_id == *selected_identity; });
if (selected != state_.agenda_items.end()) {
state_.agenda_index = static_cast<std::size_t>(selected - state_.agenda_items.begin());
return;
}
}
state_.agenda_index = std::min(state_.agenda_index, state_.agenda_items.size() - 1);
}
void TuiApp::shift_agenda(const int direction) {
if (direction == 0) return;
constexpr auto window = days{42};
state_.agenda_start_day = year_month_day{
sys_days{state_.agenda_start_day} + window * direction};
state_.agenda_items.clear();
state_.agenda_index = 0;
refresh_agenda();
}
void TuiApp::choose_agenda_item() {
if (state_.agenda_index >= state_.agenda_items.size()) return;
const auto& item = state_.agenda_items[state_.agenda_index];
state_.selected_day = item.day;
state_.visible_month = item.day.year() / item.day.month();
state_.focused_event_id = item.focus_id;
state_.show_event_details = false;
close_agenda();
}
void TuiApp::set_notification(std::string message, const bool error) {
@@ -198,6 +303,7 @@ void TuiApp::restore_history_snapshot(HistorySnapshot snapshot) {
state_.search_prompt = false;
state_.show_search_results = false;
state_.show_calendar_picker = false;
state_.show_agenda = false;
synchronize_calendars();
}
@@ -272,6 +378,7 @@ void TuiApp::begin_add() {
state_.show_help = false;
state_.confirm_delete = false;
state_.show_calendar_picker = false;
state_.show_agenda = false;
}
void TuiApp::begin_edit() {
@@ -292,6 +399,7 @@ void TuiApp::begin_edit() {
state_.show_help = false;
state_.confirm_delete = false;
state_.show_calendar_picker = false;
state_.show_agenda = false;
}
void TuiApp::begin_delete() {
@@ -304,6 +412,7 @@ void TuiApp::begin_delete() {
state_.show_event_details = false;
state_.show_help = false;
state_.show_calendar_picker = false;
state_.show_agenda = false;
}
void TuiApp::submit_editor() {
@@ -491,6 +600,7 @@ void TuiApp::choose_search_result() {
state_.visible_month = result.day.year() / result.day.month();
state_.focused_event_id = result.focus_id;
state_.show_event_details = false;
state_.show_agenda = false;
close_search();
}
@@ -611,6 +721,7 @@ void TuiApp::dispatch(const Action action) {
case Action::delete_event:
case Action::undo:
case Action::redo:
case Action::agenda:
case Action::calendars:
return;
}
@@ -652,6 +763,59 @@ void TuiApp::dispatch(const Action action) {
case Action::undo:
case Action::redo:
case Action::search:
case Action::agenda:
return;
}
}
if (state_.show_agenda) {
const auto count = state_.agenda_items.size();
switch (action) {
case Action::up:
case Action::left:
case Action::previous_event:
if (count > 0 && state_.agenda_index > 0) --state_.agenda_index;
return;
case Action::down:
case Action::right:
case Action::next_event:
if (count > 0 && state_.agenda_index + 1 < count) ++state_.agenda_index;
return;
case Action::previous_month:
shift_agenda(-1);
return;
case Action::next_month:
shift_agenda(1);
return;
case Action::today:
state_.today = current_day();
state_.agenda_start_day = state_.today;
state_.agenda_items.clear();
state_.agenda_index = 0;
refresh_agenda();
return;
case Action::select:
choose_agenda_item();
return;
case Action::back:
case Action::agenda:
close_agenda();
return;
case Action::search:
begin_search();
return;
case Action::calendars:
begin_calendar_picker();
return;
case Action::quit:
running_ = false;
return;
case Action::none:
case Action::toggle_help:
case Action::add_event:
case Action::edit_event:
case Action::delete_event:
case Action::undo:
case Action::redo:
return;
}
}
@@ -723,6 +887,9 @@ void TuiApp::dispatch(const Action action) {
case Action::search:
begin_search();
return;
case Action::agenda:
begin_agenda();
return;
case Action::calendars:
begin_calendar_picker();
return;
@@ -816,6 +983,9 @@ void TuiApp::dispatch(const Action action) {
case Action::search:
begin_search();
return;
case Action::agenda:
begin_agenda();
return;
case Action::calendars:
begin_calendar_picker();
return;