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

@@ -575,6 +575,88 @@ std::string search_frame(const ScreenState& state) {
return output.str();
}
std::string agenda_frame(const ScreenState& state) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
std::vector<std::string> lines;
lines.reserve(static_cast<std::size_t>(height));
lines.push_back(styled(centred("AGENDA", width), "1", state.ansi));
const auto start = sys_days{state.agenda_start_day};
const auto final_day = year_month_day{start + days{41}};
if (height > 1) {
const auto range = iso_date(state.agenda_start_day) + " " +
iso_date(final_day) + " (inclusive)";
lines.push_back(styled(centred(range, width), "2", state.ansi));
}
std::vector<std::size_t> ordered(state.agenda_items.size());
for (std::size_t index = 0; index < ordered.size(); ++index) ordered[index] = index;
std::stable_sort(ordered.begin(), ordered.end(), [&](const auto left_index,
const auto right_index) {
const auto& left = state.agenda_items[left_index];
const auto& right = state.agenda_items[right_index];
if (left.day != right.day) return sys_days{left.day} < sys_days{right.day};
if (left.all_day != right.all_day) return left.all_day;
return left.time_of_day.value_or(minutes{-1}) <
right.time_of_day.value_or(minutes{-1});
});
const auto footer_rows = std::min(2, std::max(0, height - 2));
const auto row_capacity = static_cast<std::size_t>(
std::max(0, height - 2 - footer_rows));
const auto selected = std::find(ordered.begin(), ordered.end(), state.agenda_index);
const auto selected_position = selected == ordered.end()
? std::size_t{0}
: static_cast<std::size_t>(selected - ordered.begin());
std::size_t first = 0;
if (row_capacity > 0 && selected != ordered.end() && selected_position >= row_capacity) {
first = selected_position - row_capacity + 1;
}
const auto last = std::min(ordered.size(), first + row_capacity);
for (auto position = first; position < last; ++position) {
const auto index = ordered[position];
const auto& item = state.agenda_items[index];
const bool focused = index == state.agenda_index;
auto when = item.all_day || !item.time_of_day
? std::string{"all day"}
: clock_time(*item.time_of_day) + " local";
auto title = item.title.empty() ? std::string{"(untitled)"}
: printable_text(item.title);
auto label = std::string{focused ? " " : " "} + iso_date(item.day) +
" " + when + " " + (item.recurring ? "" : "") + title;
if (!item.location.empty()) label += "" + printable_text(item.location);
lines.push_back(styled(fit_text(label, width), focused ? "7;1" : "", state.ansi));
}
if (state.agenda_items.empty() && row_capacity > 0) {
lines.push_back(styled(fit_text("No appointments in this 6-week window.", width),
"2", state.ansi));
}
while (static_cast<int>(lines.size()) < height - footer_rows) {
lines.emplace_back(static_cast<std::size_t>(width), ' ');
}
if (footer_rows > 0) {
lines.push_back(styled(fit_text(
"arrows/jk/Tab choose Enter month n/p 6 weeks",
width), "2", state.ansi));
}
if (footer_rows > 1) {
lines.push_back(styled(fit_text(
"c calendars / search g/Esc close", width), "2", state.ansi));
}
while (static_cast<int>(lines.size()) < height) {
lines.emplace_back(static_cast<std::size_t>(width), ' ');
}
if (static_cast<int>(lines.size()) > height) lines.resize(static_cast<std::size_t>(height));
std::ostringstream output;
for (int line = 0; line < height; ++line) {
if (line != 0) output << '\n';
output << lines[static_cast<std::size_t>(line)];
}
return output.str();
}
std::string calendar_picker_frame(const std::span<const Calendar> calendars,
const ScreenState& state) {
const int width = std::max(1, state.width);
@@ -685,7 +767,7 @@ std::string compact_frame(std::span<const CalendarItem> items, const ScreenState
if (!state.notification.empty()) {
controls = printable_text(state.notification, true);
} else if (focused == nullptr) {
controls = "c calendars / search a add u undo Ctrl-R redo ? help n/p month t today q quit";
controls = "g agenda c calendars / search a add u undo Ctrl-R redo ? help n/p month t today q quit";
} else {
controls = "Tab appointment Enter open / search e edit d delete u undo Ctrl-R redo";
}
@@ -737,6 +819,7 @@ Action decode_key(const std::string_view sequence) noexcept {
if (sequence == "u") return Action::undo;
if (sequence == "\x12") return Action::redo;
if (sequence == "/") return Action::search;
if (sequence == "g") return Action::agenda;
if (sequence == "c") return Action::calendars;
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
return Action::none;
@@ -744,6 +827,7 @@ Action decode_key(const std::string_view sequence) noexcept {
std::string render_month(std::span<const CalendarItem> items, const ScreenState& state) {
if (state.search_prompt || state.show_search_results) return search_frame(state);
if (state.show_agenda) return agenda_frame(state);
const auto focused = focused_item(items, state);
if (state.confirm_delete && focused != nullptr) {
return delete_confirmation_frame(state, *focused);
@@ -862,7 +946,7 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
? by_day.at(sys_days{state.selected_day}).size() : 0U;
std::string status;
if (state.show_help) {
status = " arrows/hjkl day Tab/⇧Tab appointment Enter read c calendars / search a add e edit d delete u undo Ctrl-R redo n/p month t today ? close q quit";
status = " arrows/hjkl day Tab/⇧Tab appointment Enter read g agenda c calendars / search a add e edit d delete u undo Ctrl-R redo n/p month t today ? close q quit";
} else if (!state.notification.empty()) {
status = " " + printable_text(state.notification, true);
} else if (focused != nullptr) {
@@ -874,7 +958,7 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) +
(selected_count == 1 ? " appointment" : " appointments") +
(selected_count > 0 ? " Tab focus" : "") +
" c calendars / search a add u undo Ctrl-R redo ? help q quit";
" g agenda c calendars / search a add u undo Ctrl-R redo ? help q quit";
}
const auto status_style = !state.notification.empty()
? (state.notification_is_error ? "1;31" : "1;32")

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;