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

@@ -6,7 +6,8 @@ inherits its colors from your terminal theme.
This repository currently contains the first local-calendar vertical slice: This repository currently contains the first local-calendar vertical slice:
date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms, date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms,
a responsive ANSI terminal UI, tests, and Linux launch integration. See [the product specification](docs/PRODUCT.md), a responsive ANSI terminal UI with month and agenda views, tests, and Linux
launch integration. See [the product specification](docs/PRODUCT.md),
[architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md). [architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md).
## Controls ## Controls
@@ -15,7 +16,11 @@ Use arrow keys or `h j k l` to move, `PageUp`/`PageDown` or `p`/`n` to change
month, and `t` for today. On a day with appointments, `Tab` and `Shift-Tab` month, and `t` for today. On a day with appointments, `Tab` and `Shift-Tab`
move focus through them and `Enter` opens the appointment reader. In the move focus through them and `Enter` opens the appointment reader. In the
reader, arrows/Vim keys or Tab variants browse the day's appointments and reader, arrows/Vim keys or Tab variants browse the day's appointments and
`Esc` returns to the month. Press `c` to open the calendar picker; arrows or `Esc` returns to the month. Press `g` to open a read-only 42-day agenda starting
on the selected date. Arrows, `j`/`k`, or Tab variants choose an occurrence;
`Enter` returns to the month at that exact occurrence. `PageUp`/`PageDown` or
`p`/`n` shift the agenda by 42 days, while `Esc` or `g` returns without changing
the month selection. Press `c` to open the calendar picker; arrows or
`j`/`k` select a calendar and `Enter` toggles its in-session visibility. Press `j`/`k` select a calendar and `Enter` toggles its in-session visibility. Press
`a` to add an appointment. Focus an `a` to add an appointment. Focus an
appointment and press `e` to edit it or `d` to delete it. Forms use `Tab` and appointment and press `e` to edit it or `d` to delete it. Forms use `Tab` and
@@ -24,6 +29,8 @@ appointment and press `e` to edit it or `d` to delete it. Forms use `Tab` and
last successful mutation and `Ctrl-R` redoes it. Press `/` to search titles, last successful mutation and `Ctrl-R` redoes it. Press `/` to search titles,
descriptions, locations, and calendar IDs. Search results use arrows or descriptions, locations, and calendar IDs. Search results use arrows or
`j`/`k`; `Enter` jumps to the exact occurrence and `Esc` returns to the month. `j`/`k`; `Enter` jumps to the exact occurrence and `Esc` returns to the month.
The calendar picker and search are also available over the agenda with `c` and
`/`. Add, edit, and delete remain month-view workflows.
Press `?` for help and `q` to quit. Press `?` for help and `q` to quit.
## Build and run ## Build and run
@@ -48,9 +55,11 @@ clearly operate on the entire series.
Search expands recurring matches within five years on either side of the Search expands recurring matches within five years on either side of the
currently selected date. This finite window keeps unbounded recurrence rules currently selected date. This finite window keeps unbounded recurrence rules
responsive while one-time and recurring results share the same chronological responsive while one-time and recurring results share the same chronological
result list. Hidden calendars are excluded consistently from the month grid, result list. Agenda recurrence expansion is bounded to its visible 42-day
appointment focus, and search. Visibility is session-only presentation state; window. Hidden calendars are excluded consistently from the month grid,
saves always retain events from hidden calendars. agenda, appointment focus, and search. Visibility and agenda position are
session-only presentation state; saves always retain events from hidden
calendars.
Calendars containing features this version cannot preserve—such as `RDATE`, Calendars containing features this version cannot preserve—such as `RDATE`,
detached recurrence overrides, ordinal `BYDAY`, embedded `VTIMEZONE` detached recurrence overrides, ordinal `BYDAY`, embedded `VTIMEZONE`

View File

@@ -42,10 +42,18 @@ rule cannot turn an interactive query into unbounded work.
Calendar visibility is session presentation state. The controller derives its Calendar visibility is session presentation state. The controller derives its
calendar list from event calendar identifiers and applies the same domain calendar list from event calendar identifiers and applies the same domain
predicate to month rendering, focus traversal, and search. Hidden events remain predicate to month rendering, agenda queries, focus traversal, and search.
in the canonical collection passed to persistence, and focus identities are Hidden events remain in the canonical collection passed to persistence, and
always derived from that complete collection so filtering cannot renumber focus identities are always derived from that complete collection so filtering
events with duplicate or missing UIDs. cannot renumber events with duplicate or missing UIDs.
The agenda is a TUI projection over domain occurrence queries, not a second
calendar model. It requests exactly one 42-day window at a time so recurrence
expansion remains finite, preserves occurrence identity when returning to the
month grid, and keeps its window and selection as non-persistent controller
state. Calendar and search overlays may be opened above it, but mutations stay
in the month workflow and continue to operate on the canonical event
collection.
Timed events retain their source basis: UTC, floating process-local time, or an Timed events retain their source basis: UTC, floating process-local time, or an
IANA `TZID`. C++20 time-zone conversion keeps zoned recurrences at their civil IANA `TZID`. C++20 time-zone conversion keeps zoned recurrences at their civil

View File

@@ -63,6 +63,9 @@ The full terminal is treated as a responsive canvas:
| Next/previous appointment | `Tab` / `Shift-Tab` | | Next/previous appointment | `Tab` / `Shift-Tab` |
| Read focused appointment | `Enter` | | Read focused appointment | `Enter` |
| Browse inside reader | arrows, `h j k l`, `Tab` / `Shift-Tab` | | Browse inside reader | arrows, `h j k l`, `Tab` / `Shift-Tab` |
| Open/close 42-day agenda | `g`; `Esc` also closes |
| Choose agenda occurrence | arrows, `j` / `k`, `Tab` / `Shift-Tab`, then `Enter` |
| Shift agenda by 42 days | `PageUp` / `PageDown`, `p` / `n` |
| Add appointment | `a` | | Add appointment | `a` |
| Edit focused appointment | `e` | | Edit focused appointment | `e` |
| Delete focused appointment | `d`, then `y` to confirm | | Delete focused appointment | `d`, then `y` to confirm |
@@ -77,7 +80,8 @@ The full terminal is treated as a responsive canvas:
| Quit | `q` | | Quit | `q` |
The first usable foundation supports day and appointment navigation, a The first usable foundation supports day and appointment navigation, a
full-frame reader, validated add/edit/delete forms, and atomic `.ics` writes. full-frame reader, a read-only 42-day agenda, validated add/edit/delete forms,
and atomic `.ics` writes.
Dense days keep every appointment keyboard-reachable even when all lines do Dense days keep every appointment keyboard-reachable even when all lines do
not fit in the cell. Unknown or unsupported iCalendar content makes a source not fit in the cell. Unknown or unsupported iCalendar content makes a source
read-only instead of being discarded. Saves reject external changes, retain a read-only instead of being discarded. Saves reject external changes, retain a
@@ -88,8 +92,14 @@ semantics in the reader. `/` searches occurrence-aware title, description,
location, and calendar-ID matches in a finite ten-year window centered on the location, and calendar-ID matches in a finite ten-year window centered on the
selected date. `c` toggles session-only calendar visibility consistently across selected date. `c` toggles session-only calendar visibility consistently across
the grid, appointment focus, and search without filtering the model supplied to the grid, appointment focus, and search without filtering the model supplied to
persistence. The next local-data work adds an agenda view, then advanced persistence. `g` opens an occurrence-aware agenda at the selected month date;
recurrence overrides and rule editing. navigation is bounded to consecutive 42-day windows and `Enter` returns to the
exact occurrence in the month grid. `Esc` or `g` closes the agenda without
changing the month selection unless an occurrence was chosen. Hidden calendars
remain filtered there, while calendar selection and search can overlay the
agenda. Agenda navigation never persists state, and add/edit/delete remain in
the month workflow. The next local-data work adds advanced recurrence overrides
and rule editing.
## Accessibility ## Accessibility

View File

@@ -11,13 +11,14 @@
- Windowed recurrence expansion, EXDATE, IANA TZID, and multi-day segment cues - Windowed recurrence expansion, EXDATE, IANA TZID, and multi-day segment cues
- Occurrence-aware appointment search with exact result-to-grid navigation - Occurrence-aware appointment search with exact result-to-grid navigation
- Session calendar toggles shared by month rendering, focus, and search - Session calendar toggles shared by month rendering, focus, and search
- Read-only 42-day agenda with bounded recurrence and exact return navigation
- Meson build, Nix development shell, desktop entry, and Hyprland launcher - Meson build, Nix development shell, desktop entry, and Hyprland launcher
- Seeded sample data when explicitly requested, never silent data mutation - Seeded sample data when explicitly requested, never silent data mutation
## 0.1 — complete local calendar ## 0.1 — complete local calendar
- Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support - Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support
- Agenda view, import/export, configurable week start - Import/export and configurable week start
- Screen-reader-friendly linear view and full Unicode display-width handling - Screen-reader-friendly linear view and full Unicode display-width handling
## 0.2 — durable cache and provider boundary ## 0.2 — durable cache and provider boundary

View File

@@ -51,6 +51,17 @@ struct SearchResultItem {
bool recurring{false}; bool recurring{false};
}; };
struct AgendaItem {
std::string focus_id;
std::string title;
std::chrono::year_month_day day;
std::optional<std::chrono::minutes> time_of_day;
bool all_day{false};
std::string location;
std::string calendar_id;
bool recurring{false};
};
struct ScreenState { struct ScreenState {
std::chrono::year_month visible_month{std::chrono::year{1970}, std::chrono::month{1}}; std::chrono::year_month visible_month{std::chrono::year{1970}, std::chrono::month{1}};
std::chrono::year_month_day selected_day{std::chrono::year{1970}, std::chrono::month{1}, std::chrono::year_month_day selected_day{std::chrono::year{1970}, std::chrono::month{1},
@@ -73,6 +84,11 @@ struct ScreenState {
bool notification_is_error{false}; bool notification_is_error{false};
bool show_calendar_picker{false}; bool show_calendar_picker{false};
std::size_t calendar_index{0}; std::size_t calendar_index{0};
bool show_agenda{false};
std::chrono::year_month_day agenda_start_day{
std::chrono::year{1970}, std::chrono::month{1}, std::chrono::day{1}};
std::vector<AgendaItem> agenda_items;
std::size_t agenda_index{0};
}; };
// Render a complete frame. The result contains no cursor positioning, which // Render a complete frame. The result contains no cursor positioning, which
@@ -117,6 +133,7 @@ enum class Action {
undo, undo,
redo, redo,
search, search,
agenda,
calendars, calendars,
quit, quit,
}; };

View File

@@ -48,6 +48,7 @@ public:
[[nodiscard]] bool calendar_picker_active() const noexcept { [[nodiscard]] bool calendar_picker_active() const noexcept {
return state_.show_calendar_picker; return state_.show_calendar_picker;
} }
[[nodiscard]] bool agenda_active() const noexcept { return state_.show_agenda; }
[[nodiscard]] const std::vector<Calendar>& calendars() const noexcept { [[nodiscard]] const std::vector<Calendar>& calendars() const noexcept {
return calendars_; return calendars_;
} }
@@ -89,6 +90,11 @@ private:
void begin_calendar_picker(); void begin_calendar_picker();
void close_calendar_picker(); void close_calendar_picker();
void toggle_selected_calendar(); void toggle_selected_calendar();
void begin_agenda();
void close_agenda();
void refresh_agenda();
void shift_agenda(int direction);
void choose_agenda_item();
[[nodiscard]] HistorySnapshot capture_history_snapshot() const; [[nodiscard]] HistorySnapshot capture_history_snapshot() const;
void restore_history_snapshot(HistorySnapshot snapshot); void restore_history_snapshot(HistorySnapshot snapshot);
void push_history(std::vector<HistoryEntry>& history, HistoryEntry entry); void push_history(std::vector<HistoryEntry>& history, HistoryEntry entry);

View File

@@ -57,6 +57,8 @@ void print_help(std::ostream& output)
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n" " Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n" " a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
" u undoes the last change, Ctrl-R redoes it,\n" " u undoes the last change, Ctrl-R redoes it,\n"
" g opens the 42-day agenda; arrows/jk/Tab choose, Enter jumps,\n"
" PageUp/PageDown or p/n shift its window, Esc/g returns,\n"
" c toggles calendar visibility, / searches appointments,\n" " c toggles calendar visibility, / searches appointments,\n"
" t jumps to today, ? toggles help, q quits.\n"; " t jumps to today, ? toggles help, q quits.\n";
} }
@@ -159,6 +161,12 @@ int print_frame(std::span<const nocal::Event> events, bool no_color)
.search_result_index = 0, .search_result_index = 0,
.notification = {}, .notification = {},
.notification_is_error = false, .notification_is_error = false,
.show_calendar_picker = false,
.calendar_index = 0,
.show_agenda = false,
.agenda_start_day = day,
.agenda_items = {},
.agenda_index = 0,
}; };
std::cout << nocal::tui::render_month(events, state) << '\n'; std::cout << nocal::tui::render_month(events, state) << '\n';
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE; return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;

View File

@@ -575,6 +575,88 @@ std::string search_frame(const ScreenState& state) {
return output.str(); 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, std::string calendar_picker_frame(const std::span<const Calendar> calendars,
const ScreenState& state) { const ScreenState& state) {
const int width = std::max(1, state.width); 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()) { if (!state.notification.empty()) {
controls = printable_text(state.notification, true); controls = printable_text(state.notification, true);
} else if (focused == nullptr) { } 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 { } else {
controls = "Tab appointment Enter open / search e edit d delete u undo Ctrl-R redo"; 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 == "u") return Action::undo;
if (sequence == "\x12") return Action::redo; if (sequence == "\x12") return Action::redo;
if (sequence == "/") return Action::search; if (sequence == "/") return Action::search;
if (sequence == "g") return Action::agenda;
if (sequence == "c") return Action::calendars; if (sequence == "c") return Action::calendars;
if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit; if (sequence == "q" || sequence == "Q" || sequence == "\x03") return Action::quit;
return Action::none; 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) { 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.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); const auto focused = focused_item(items, state);
if (state.confirm_delete && focused != nullptr) { if (state.confirm_delete && focused != nullptr) {
return delete_confirmation_frame(state, *focused); 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; ? by_day.at(sys_days{state.selected_day}).size() : 0U;
std::string status; std::string status;
if (state.show_help) { 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()) { } else if (!state.notification.empty()) {
status = " " + printable_text(state.notification, true); status = " " + printable_text(state.notification, true);
} else if (focused != nullptr) { } 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) + status = " " + iso_date(state.selected_day) + " " + std::to_string(selected_count) +
(selected_count == 1 ? " appointment" : " appointments") + (selected_count == 1 ? " appointment" : " appointments") +
(selected_count > 0 ? " Tab focus" : "") + (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() const auto status_style = !state.notification.empty()
? (state.notification_is_error ? "1;31" : "1;32") ? (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_.focused_event_id.reset();
state_.show_event_details = false; 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) { 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_.search_prompt = false;
state_.show_search_results = false; state_.show_search_results = false;
state_.show_calendar_picker = false; state_.show_calendar_picker = false;
state_.show_agenda = false;
synchronize_calendars(); synchronize_calendars();
} }
@@ -272,6 +378,7 @@ void TuiApp::begin_add() {
state_.show_help = false; state_.show_help = false;
state_.confirm_delete = false; state_.confirm_delete = false;
state_.show_calendar_picker = false; state_.show_calendar_picker = false;
state_.show_agenda = false;
} }
void TuiApp::begin_edit() { void TuiApp::begin_edit() {
@@ -292,6 +399,7 @@ void TuiApp::begin_edit() {
state_.show_help = false; state_.show_help = false;
state_.confirm_delete = false; state_.confirm_delete = false;
state_.show_calendar_picker = false; state_.show_calendar_picker = false;
state_.show_agenda = false;
} }
void TuiApp::begin_delete() { void TuiApp::begin_delete() {
@@ -304,6 +412,7 @@ void TuiApp::begin_delete() {
state_.show_event_details = false; state_.show_event_details = false;
state_.show_help = false; state_.show_help = false;
state_.show_calendar_picker = false; state_.show_calendar_picker = false;
state_.show_agenda = false;
} }
void TuiApp::submit_editor() { void TuiApp::submit_editor() {
@@ -491,6 +600,7 @@ void TuiApp::choose_search_result() {
state_.visible_month = result.day.year() / result.day.month(); state_.visible_month = result.day.year() / result.day.month();
state_.focused_event_id = result.focus_id; state_.focused_event_id = result.focus_id;
state_.show_event_details = false; state_.show_event_details = false;
state_.show_agenda = false;
close_search(); close_search();
} }
@@ -611,6 +721,7 @@ void TuiApp::dispatch(const Action action) {
case Action::delete_event: case Action::delete_event:
case Action::undo: case Action::undo:
case Action::redo: case Action::redo:
case Action::agenda:
case Action::calendars: case Action::calendars:
return; return;
} }
@@ -652,6 +763,59 @@ void TuiApp::dispatch(const Action action) {
case Action::undo: case Action::undo:
case Action::redo: case Action::redo:
case Action::search: 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; return;
} }
} }
@@ -723,6 +887,9 @@ void TuiApp::dispatch(const Action action) {
case Action::search: case Action::search:
begin_search(); begin_search();
return; return;
case Action::agenda:
begin_agenda();
return;
case Action::calendars: case Action::calendars:
begin_calendar_picker(); begin_calendar_picker();
return; return;
@@ -816,6 +983,9 @@ void TuiApp::dispatch(const Action action) {
case Action::search: case Action::search:
begin_search(); begin_search();
return; return;
case Action::agenda:
begin_agenda();
return;
case Action::calendars: case Action::calendars:
begin_calendar_picker(); begin_calendar_picker();
return; return;

View File

@@ -191,6 +191,7 @@ void test_key_decoding()
check(decode_key("d") == Action::delete_event, "d decodes as delete appointment"); 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("u") == Action::undo, "u decodes as undo");
check(decode_key("\x12") == Action::redo, "Ctrl-R decodes as redo"); 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() 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"); 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 } // namespace
int main() int main()
@@ -1067,6 +1244,9 @@ int main()
test_recurrence_grid_expansion_and_source_zone_display(); test_recurrence_grid_expansion_and_source_zone_display();
test_search_prompt_results_and_occurrence_jump(); test_search_prompt_results_and_occurrence_jump();
test_calendar_visibility_filters_without_partial_saves(); 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) { if (failures != 0) {
std::cerr << failures << " TUI focus test(s) failed\n"; std::cerr << failures << " TUI focus test(s) failed\n";
return EXIT_FAILURE; return EXIT_FAILURE;

View File

@@ -43,6 +43,12 @@ void test_full_month_render()
.search_result_index = 0, .search_result_index = 0,
.notification = {}, .notification = {},
.notification_is_error = false, .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{ const std::vector<nocal::tui::CalendarItem> items{
{.id = "1", .title = "Design review", .day = year{2026} / July / day{17}, {.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("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("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("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, check(std::count(frame.begin(), frame.end(), '\n') == 29,
"renderer fills the requested height deterministically"); "renderer fills the requested height deterministically");
check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled"); check(frame.find("\x1b[") == std::string::npos, "ANSI can be disabled");
@@ -91,6 +99,12 @@ void test_compact_and_input()
.search_result_index = 0, .search_result_index = 0,
.notification = {}, .notification = {},
.notification_is_error = false, .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); 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"); 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"); "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 } // namespace
int main() int main()
@@ -199,6 +296,9 @@ int main()
test_navigation(); test_navigation();
test_search_frames(); test_search_frames();
test_calendar_picker_frame(); test_calendar_picker_frame();
test_agenda_empty_frame();
test_agenda_selection_and_scrolling();
test_agenda_input_and_search_precedence();
if (failures != 0) { if (failures != 0) {
std::cerr << failures << " TUI test(s) failed\n"; std::cerr << failures << " TUI test(s) failed\n";
return EXIT_FAILURE; return EXIT_FAILURE;