diff --git a/README.md b/README.md index 73018fe..a1a476f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ inherits its colors from your terminal theme. This repository currently contains the first local-calendar vertical slice: 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). ## 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` 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 -`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 `a` to add an appointment. Focus an 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, descriptions, locations, and calendar IDs. Search results use arrows or `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. ## 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 currently selected date. This finite window keeps unbounded recurrence rules responsive while one-time and recurring results share the same chronological -result list. Hidden calendars are excluded consistently from the month grid, -appointment focus, and search. Visibility is session-only presentation state; -saves always retain events from hidden calendars. +result list. Agenda recurrence expansion is bounded to its visible 42-day +window. Hidden calendars are excluded consistently from the month grid, +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`, detached recurrence overrides, ordinal `BYDAY`, embedded `VTIMEZONE` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2dc7a3b..4c7bb35 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -42,10 +42,18 @@ rule cannot turn an interactive query into unbounded work. Calendar visibility is session presentation state. The controller derives its calendar list from event calendar identifiers and applies the same domain -predicate to month rendering, focus traversal, and search. Hidden events remain -in the canonical collection passed to persistence, and focus identities are -always derived from that complete collection so filtering cannot renumber -events with duplicate or missing UIDs. +predicate to month rendering, agenda queries, focus traversal, and search. +Hidden events remain in the canonical collection passed to persistence, and +focus identities are always derived from that complete collection so filtering +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 IANA `TZID`. C++20 time-zone conversion keeps zoned recurrences at their civil diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 3bb685c..13b2120 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -63,6 +63,9 @@ The full terminal is treated as a responsive canvas: | Next/previous appointment | `Tab` / `Shift-Tab` | | Read focused appointment | `Enter` | | 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` | | Edit focused appointment | `e` | | Delete focused appointment | `d`, then `y` to confirm | @@ -77,7 +80,8 @@ The full terminal is treated as a responsive canvas: | Quit | `q` | 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 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 @@ -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 selected date. `c` toggles session-only calendar visibility consistently across the grid, appointment focus, and search without filtering the model supplied to -persistence. The next local-data work adds an agenda view, then advanced -recurrence overrides and rule editing. +persistence. `g` opens an occurrence-aware agenda at the selected month date; +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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1c31ffc..11d121b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -11,13 +11,14 @@ - Windowed recurrence expansion, EXDATE, IANA TZID, and multi-day segment cues - Occurrence-aware appointment search with exact result-to-grid navigation - 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 - Seeded sample data when explicitly requested, never silent data mutation ## 0.1 — complete local calendar - 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 ## 0.2 — durable cache and provider boundary diff --git a/include/nocal/tui/Screen.hpp b/include/nocal/tui/Screen.hpp index a28b648..52d0233 100644 --- a/include/nocal/tui/Screen.hpp +++ b/include/nocal/tui/Screen.hpp @@ -51,6 +51,17 @@ struct SearchResultItem { bool recurring{false}; }; +struct AgendaItem { + std::string focus_id; + std::string title; + std::chrono::year_month_day day; + std::optional time_of_day; + bool all_day{false}; + std::string location; + std::string calendar_id; + bool recurring{false}; +}; + struct ScreenState { 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}, @@ -73,6 +84,11 @@ struct ScreenState { bool notification_is_error{false}; bool show_calendar_picker{false}; 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 agenda_items; + std::size_t agenda_index{0}; }; // Render a complete frame. The result contains no cursor positioning, which @@ -117,6 +133,7 @@ enum class Action { undo, redo, search, + agenda, calendars, quit, }; diff --git a/include/nocal/tui/TuiApp.hpp b/include/nocal/tui/TuiApp.hpp index b7ee364..164b822 100644 --- a/include/nocal/tui/TuiApp.hpp +++ b/include/nocal/tui/TuiApp.hpp @@ -48,6 +48,7 @@ public: [[nodiscard]] bool calendar_picker_active() const noexcept { return state_.show_calendar_picker; } + [[nodiscard]] bool agenda_active() const noexcept { return state_.show_agenda; } [[nodiscard]] const std::vector& calendars() const noexcept { return calendars_; } @@ -89,6 +90,11 @@ private: void begin_calendar_picker(); void close_calendar_picker(); 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; void restore_history_snapshot(HistorySnapshot snapshot); void push_history(std::vector& history, HistoryEntry entry); diff --git a/src/main.cpp b/src/main.cpp index ecedd8c..972b6f9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -57,6 +57,8 @@ void print_help(std::ostream& output) " Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n" " a adds, e edits, d deletes; Ctrl-S saves inside the editor,\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" " t jumps to today, ? toggles help, q quits.\n"; } @@ -159,6 +161,12 @@ int print_frame(std::span events, bool no_color) .search_result_index = 0, .notification = {}, .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'; return std::cout ? EXIT_SUCCESS : EXIT_FAILURE; diff --git a/src/tui/Screen.cpp b/src/tui/Screen.cpp index 749afda..583ee33 100644 --- a/src/tui/Screen.cpp +++ b/src/tui/Screen.cpp @@ -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 lines; + lines.reserve(static_cast(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 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::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(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(lines.size()) < height - footer_rows) { + lines.emplace_back(static_cast(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(lines.size()) < height) { + lines.emplace_back(static_cast(width), ' '); + } + if (static_cast(lines.size()) > height) lines.resize(static_cast(height)); + + std::ostringstream output; + for (int line = 0; line < height; ++line) { + if (line != 0) output << '\n'; + output << lines[static_cast(line)]; + } + return output.str(); +} + std::string calendar_picker_frame(const std::span calendars, const ScreenState& state) { const int width = std::max(1, state.width); @@ -685,7 +767,7 @@ std::string compact_frame(std::span 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 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 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 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") diff --git a/src/tui/TuiApp.cpp b/src/tui/TuiApp.cpp index 849133c..175f6cd 100644 --- a/src/tui/TuiApp.cpp +++ b/src/tui/TuiApp.cpp @@ -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 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{events_}; + const auto calendar_span = std::span{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(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; diff --git a/tests/tui_focus_tests.cpp b/tests/tui_focus_tests.cpp index 47b83a3..dbb89a8 100644 --- a/tests/tui_focus_tests.cpp +++ b/tests/tui_focus_tests.cpp @@ -191,6 +191,7 @@ void test_key_decoding() 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("\x12") == Action::redo, "Ctrl-R decodes as redo"); + check(decode_key("g") == Action::agenda, "g decodes as agenda"); } 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"); } +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 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) { ++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 events{series}; + int save_calls = 0; + nocal::tui::TuiApp app{ + events, [&](std::span) { ++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 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 events{ + make_event("agenda-default", "Default", day, 7, 0, day, 8, 0), personal}; + int save_calls = 0; + nocal::tui::TuiApp app{ + events, [&](std::span) { ++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 int main() @@ -1067,6 +1244,9 @@ int main() test_recurrence_grid_expansion_and_source_zone_display(); test_search_prompt_results_and_occurrence_jump(); 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) { std::cerr << failures << " TUI focus test(s) failed\n"; return EXIT_FAILURE; diff --git a/tests/tui_tests.cpp b/tests/tui_tests.cpp index 7ea9861..2516cb2 100644 --- a/tests/tui_tests.cpp +++ b/tests/tui_tests.cpp @@ -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 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{}, 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{}, 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{}, 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{}, 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;