diff --git a/README.md b/README.md index da8cbd6..2bb6a19 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,11 @@ Run the tests with `meson test -C build --print-errorlogs`. Nocal reads `$XDG_DATA_HOME/nocal/calendar.ics` by default (falling back to `~/.local/share/nocal/calendar.ics`), or accepts another `.ics` path as its positional argument. `--demo` adds a few unsaved sample appointments and -`--print` emits a non-interactive frame. +`--print` emits a non-interactive frame. Weeks start on Monday by default. Pass +`--week-start DAY`, where `DAY` is exactly one of `monday`, `tuesday`, +`wednesday`, `thursday`, `friday`, `saturday`, or `sunday`, to rotate the month +grid and its weekday header. This is display-only: it does not change stored +events, recurrence interpretation, agenda windows, or calendar interchange. Calendar interchange is explicit, non-interactive, and network-free: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 89f59b3..45086ff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,7 +20,11 @@ future sync workers The domain layer uses C++20 `` civil dates and time points. It knows nothing about escape sequences, files, OAuth, or HTTP. Month layout always -produces 42 cells, starting on Monday, which keeps redraw geometry stable. +produces 42 cells, starting on an application-selected weekday (Monday by +default), which keeps redraw geometry stable. The selected week start rotates +the header, grid spill cells, and the finite range queried for that grid. It is +presentation input only: agenda windows, recurrence rules, and storage remain +independent of it. Events retain a stable UID, title, half-open start/end interval, all-day flag, optional descriptive fields, and calendar identity. Queries define overlap as diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 84e81c5..8cbcc77 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -8,7 +8,8 @@ typeface and palette; Nocal owns hierarchy, spacing, and restrained emphasis. ## Product principles 1. **The month is the home screen.** Launching Nocal immediately shows six - complete Monday-first weeks. There is no dashboard or splash screen. + complete weeks, Monday-first by default. `--week-start` can rotate the grid + to any weekday. There is no dashboard or splash screen. 2. **Useful at a glance.** Every visible day shows as many appointments as fit, ordered as all-day first and then by start time. A final `+N more` line is used instead of clipping silently. @@ -98,8 +99,12 @@ 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. +the month workflow. Week-start selection rotates only the month grid, header, +and the visible range used by its month queries; storage, agenda windows, and +recurrence rules are unchanged. The default remains Monday for backward +compatibility. The CLI accepts the exact lowercase values `monday`, `tuesday`, +`wednesday`, `thursday`, `friday`, `saturday`, and `sunday`. The next local-data +work adds advanced recurrence overrides and rule editing. ## Local file interchange diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2426217..6be613c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,6 +12,7 @@ - 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 +- Configurable week start with Monday-default CLI and consistent grid queries - Explicit atomic import/export with validation, collision refusal, and backups - Meson build, Nix development shell, desktop entry, and Hyprland launcher - Seeded sample data when explicitly requested, never silent data mutation @@ -19,7 +20,6 @@ ## 0.1 — complete local calendar - Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support -- 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/domain/date.hpp b/include/nocal/domain/date.hpp index 9c05b8e..3dcbefd 100644 --- a/include/nocal/domain/date.hpp +++ b/include/nocal/domain/date.hpp @@ -31,6 +31,8 @@ using YearMonth = std::chrono::year_month; [[nodiscard]] Date first_day_of_month(YearMonth month); [[nodiscard]] YearMonth following_month(YearMonth month); +[[nodiscard]] unsigned weekday_index(Date date, + std::chrono::weekday week_start); [[nodiscard]] unsigned monday_first_weekday(Date date); } // namespace nocal diff --git a/include/nocal/domain/month_layout.hpp b/include/nocal/domain/month_layout.hpp index ed48a33..8a3a253 100644 --- a/include/nocal/domain/month_layout.hpp +++ b/include/nocal/domain/month_layout.hpp @@ -20,6 +20,7 @@ struct MonthLayout { static constexpr std::size_t cell_count = rows * columns; YearMonth month{std::chrono::year{1970} / std::chrono::January}; + std::chrono::weekday week_start{std::chrono::Monday}; std::array cells{}; [[nodiscard]] const MonthCell& at(std::size_t row, std::size_t column) const; @@ -28,6 +29,7 @@ struct MonthLayout { [[nodiscard]] Date last_visible_date() const noexcept; }; -[[nodiscard]] MonthLayout make_month_layout(YearMonth month); +[[nodiscard]] MonthLayout make_month_layout( + YearMonth month, std::chrono::weekday week_start = std::chrono::Monday); } // namespace nocal diff --git a/include/nocal/tui/Screen.hpp b/include/nocal/tui/Screen.hpp index 52d0233..4582014 100644 --- a/include/nocal/tui/Screen.hpp +++ b/include/nocal/tui/Screen.hpp @@ -89,6 +89,7 @@ struct ScreenState { std::chrono::year{1970}, std::chrono::month{1}, std::chrono::day{1}}; std::vector agenda_items; std::size_t agenda_index{0}; + std::chrono::weekday week_start{std::chrono::Monday}; }; // Render a complete frame. The result contains no cursor positioning, which diff --git a/include/nocal/tui/TuiApp.hpp b/include/nocal/tui/TuiApp.hpp index 164b822..00f36bd 100644 --- a/include/nocal/tui/TuiApp.hpp +++ b/include/nocal/tui/TuiApp.hpp @@ -26,8 +26,12 @@ public: using SaveCallback = std::function)>; explicit TuiApp(std::vector& events, int input_fd = 0, int output_fd = 1); + TuiApp(std::vector& events, std::chrono::weekday week_start, + int input_fd = 0, int output_fd = 1); TuiApp(std::vector& events, SaveCallback saver, int input_fd = 0, int output_fd = 1); + TuiApp(std::vector& events, SaveCallback saver, + std::chrono::weekday week_start, int input_fd = 0, int output_fd = 1); [[nodiscard]] ExitReason run(); void dispatch(Action action); diff --git a/meson.build b/meson.build index 3c2df2c..cbd8db8 100644 --- a/meson.build +++ b/meson.build @@ -53,6 +53,8 @@ test('cli-recovery', shell, args: [files('tests/cli_recovery_test.sh'), nocal_executable]) test('cli-transfer', shell, args: [files('tests/cli_transfer_test.sh'), nocal_executable]) +test('cli-week-start', shell, + args: [files('tests/cli_week_start_test.sh'), nocal_executable]) install_data('scripts/nocal-hyprland', install_dir: get_option('bindir'), install_mode: 'rwxr-xr-x') diff --git a/src/domain/date.cpp b/src/domain/date.cpp index efc43da..7f80630 100644 --- a/src/domain/date.cpp +++ b/src/domain/date.cpp @@ -163,10 +163,20 @@ YearMonth following_month(YearMonth month) return month + std::chrono::months{1}; } +unsigned weekday_index(Date date, std::chrono::weekday week_start) +{ + if (!week_start.ok()) { + throw std::invalid_argument("invalid week-start weekday"); + } + + const auto date_weekday = + std::chrono::weekday{to_sys_days(date)}.c_encoding(); + return (date_weekday + 7U - week_start.c_encoding()) % 7U; +} + unsigned monday_first_weekday(Date date) { - const auto weekday = std::chrono::weekday{to_sys_days(date)}.c_encoding(); - return (weekday + 6U) % 7U; + return weekday_index(date, std::chrono::Monday); } } // namespace nocal diff --git a/src/domain/month_layout.cpp b/src/domain/month_layout.cpp index 2c339eb..eb6736e 100644 --- a/src/domain/month_layout.cpp +++ b/src/domain/month_layout.cpp @@ -32,14 +32,19 @@ Date MonthLayout::last_visible_date() const noexcept return cells.back().date; } -MonthLayout make_month_layout(YearMonth month) +MonthLayout make_month_layout(YearMonth month, std::chrono::weekday week_start) { + if (!week_start.ok()) { + throw std::invalid_argument("invalid week-start weekday"); + } + const auto first = first_day_of_month(month); const auto grid_start = to_sys_days(first) - - std::chrono::days{monday_first_weekday(first)}; + std::chrono::days{weekday_index(first, week_start)}; MonthLayout result; result.month = month; + result.week_start = week_start; for (std::size_t index = 0; index < result.cells.size(); ++index) { const auto date = from_sys_days(grid_start + std::chrono::days{static_cast(index)}); diff --git a/src/main.cpp b/src/main.cpp index a714503..c21de7e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -26,6 +26,7 @@ struct Options { std::filesystem::path calendar_path; std::optional import_path; std::optional export_path; + std::chrono::weekday week_start{std::chrono::Monday}; bool demo{false}; bool print{false}; bool restore_backup{false}; @@ -50,6 +51,7 @@ void print_help(std::ostream& output) "Usage: nocal [OPTIONS] [CALENDAR.ics]\n\n" "A theme-native terminal month calendar.\n\n" " -c, --calendar PATH read events from PATH\n" + " --week-start DAY set first weekday: monday through sunday\n" " --demo add sample appointments without saving them\n" " --print print one plain-text frame and exit\n" " --restore-backup restore PATH.bak over PATH and exit\n" @@ -68,18 +70,32 @@ void print_help(std::ostream& output) " t jumps to today, ? toggles help, q quits.\n"; } +std::optional parse_week_start(const std::string_view value) +{ + if (value == "monday") return std::chrono::Monday; + if (value == "tuesday") return std::chrono::Tuesday; + if (value == "wednesday") return std::chrono::Wednesday; + if (value == "thursday") return std::chrono::Thursday; + if (value == "friday") return std::chrono::Friday; + if (value == "saturday") return std::chrono::Saturday; + if (value == "sunday") return std::chrono::Sunday; + return std::nullopt; +} + Options parse_options(int argc, char** argv) { Options options{ .calendar_path = default_calendar_path(), .import_path = std::nullopt, .export_path = std::nullopt, + .week_start = std::chrono::Monday, .demo = false, .print = false, .restore_backup = false, .no_color = false, }; bool positional_seen = false; + bool week_start_seen = false; for (int index = 1; index < argc; ++index) { const std::string_view argument{argv[index]}; if (argument == "-h" || argument == "--help") { @@ -98,6 +114,22 @@ Options parse_options(int argc, char** argv) options.restore_backup = true; } else if (argument == "--no-color") { options.no_color = true; + } else if (argument == "--week-start") { + if (week_start_seen) { + throw std::invalid_argument("--week-start may only be supplied once"); + } + if (++index >= argc) { + throw std::invalid_argument("--week-start requires a day"); + } + const std::string_view value{argv[index]}; + const auto parsed = parse_week_start(value); + if (!parsed) { + throw std::invalid_argument( + "invalid --week-start day: " + std::string{value} + + " (expected monday through sunday)"); + } + options.week_start = *parsed; + week_start_seen = true; } else if (argument == "--import" || argument == "--export") { if (++index >= argc) { throw std::invalid_argument(std::string{argument} + " requires a path"); @@ -182,7 +214,8 @@ std::vector demo_events() return {std::move(planning), std::move(release), std::move(coffee)}; } -int print_frame(std::span events, bool no_color) +int print_frame(std::span events, bool no_color, + const std::chrono::weekday week_start) { const auto day = nocal::today(); const nocal::tui::ScreenState state{ @@ -205,6 +238,7 @@ int print_frame(std::span events, bool no_color) .agenda_start_day = day, .agenda_items = {}, .agenda_index = 0, + .week_start = week_start, }; std::cout << nocal::tui::render_month(events, state) << '\n'; return std::cout ? EXIT_SUCCESS : EXIT_FAILURE; @@ -304,7 +338,7 @@ int main(int argc, char** argv) const bool non_interactive = ::isatty(STDIN_FILENO) == 0 || ::isatty(STDOUT_FILENO) == 0; if (options.print || non_interactive) { - return print_frame(loaded.events, true); + return print_frame(loaded.events, true, options.week_start); } if (options.no_color) { ::setenv("NO_COLOR", "1", 1); @@ -325,7 +359,8 @@ int main(int argc, char** argv) }; } } - nocal::tui::TuiApp app{loaded.events, std::move(saver)}; + nocal::tui::TuiApp app{ + loaded.events, std::move(saver), options.week_start}; return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS; } catch (const std::exception& error) { std::cerr << "nocal: " << error.what() << '\n'; diff --git a/src/tui/Screen.cpp b/src/tui/Screen.cpp index 583ee33..a8ac8a3 100644 --- a/src/tui/Screen.cpp +++ b/src/tui/Screen.cpp @@ -196,12 +196,21 @@ std::string border(const std::vector& widths, const char* left, return result; } -sys_days monday_on_or_before(const sys_days date) { - const weekday day{date}; - const auto offset = (day.iso_encoding() + 6) % 7; +weekday valid_week_start(const weekday requested) { + return requested.ok() ? requested : Monday; +} + +sys_days week_start_on_or_before(const sys_days date, const weekday requested_start) { + const auto start = valid_week_start(requested_start); + const auto offset = (weekday{date}.c_encoding() + 7U - start.c_encoding()) % 7U; return date - days{offset}; } +std::size_t weekday_name_index(const weekday requested_start, const std::size_t column) { + const auto start = valid_week_start(requested_start); + return (static_cast(start.iso_encoding() - 1U) + column) % 7U; +} + struct LocalMoment { year_month_day date; minutes time_of_day; @@ -714,7 +723,7 @@ std::string compact_frame(std::span items, const ScreenState const int width = std::max(1, state.width); const int height = std::max(1, state.height); const auto first = sys_days{state.visible_month / day{1}}; - const auto grid_start = monday_on_or_before(first); + const auto grid_start = week_start_on_or_before(first, state.week_start); std::vector lines; const auto title = std::string{month_names[static_cast(state.visible_month.month()) - 1]} + " " + std::to_string(static_cast(state.visible_month.year())); @@ -726,7 +735,8 @@ std::string compact_frame(std::span items, const ScreenState } std::string weekdays; for (std::size_t column = 0; column < weekday_short.size(); ++column) { - weekdays += centred(weekday_short[column], tokens[column]); + weekdays += centred(weekday_short[weekday_name_index(state.week_start, column)], + tokens[column]); } lines.push_back(styled(weekdays, "2;1", state.ansi)); for (int week = 0; week < 6; ++week) { @@ -853,7 +863,7 @@ std::string render_month(std::span items, const ScreenState& } const auto first = sys_days{state.visible_month / day{1}}; - const auto grid_start = monday_on_or_before(first); + const auto grid_start = week_start_on_or_before(first, state.week_start); std::map> by_day; for (const auto& item : items) { if (item.day.ok()) { @@ -871,7 +881,9 @@ std::string render_month(std::span items, const ScreenState& output << styled(centred(title, width), "1", state.ansi) << '\n'; output << ' '; for (std::size_t column = 0; column < widths.size(); ++column) { - const auto name = widths[column] >= 9 ? weekday_long[column] : weekday_short[column]; + const auto name_index = weekday_name_index(state.week_start, column); + const auto name = widths[column] >= 9 ? weekday_long[name_index] + : weekday_short[name_index]; output << styled(centred(name, widths[column]), "2;1", state.ansi); output << (column + 1 == widths.size() ? ' ' : ' '); } @@ -977,7 +989,8 @@ std::string render_month(const std::span events, if (state.show_calendar_picker) return calendar_picker_frame(calendars, state); std::vector items; items.reserve(events.size() * 2); - const auto visible_start = monday_on_or_before(sys_days{state.visible_month / day{1}}); + const auto visible_start = week_start_on_or_before( + sys_days{state.visible_month / day{1}}, state.week_start); const auto visible_end = visible_start + days{41}; const auto occurrences = occurrences_overlapping( events, local_day_start(year_month_day{visible_start}), diff --git a/src/tui/TuiApp.cpp b/src/tui/TuiApp.cpp index 175f6cd..dee7384 100644 --- a/src/tui/TuiApp.cpp +++ b/src/tui/TuiApp.cpp @@ -78,16 +78,26 @@ bool is_search_text(const std::string_view input) { } // namespace TuiApp::TuiApp(std::vector& events, const int input_fd, const int output_fd) - : TuiApp(events, SaveCallback{}, input_fd, output_fd) {} + : TuiApp(events, SaveCallback{}, std::chrono::Monday, input_fd, output_fd) {} + +TuiApp::TuiApp(std::vector& events, const std::chrono::weekday week_start, + const int input_fd, const int output_fd) + : TuiApp(events, SaveCallback{}, week_start, input_fd, output_fd) {} TuiApp::TuiApp(std::vector& events, SaveCallback saver, const int input_fd, const int output_fd) + : TuiApp(events, std::move(saver), std::chrono::Monday, input_fd, output_fd) {} + +TuiApp::TuiApp(std::vector& events, SaveCallback saver, + const std::chrono::weekday week_start, const int input_fd, + const int output_fd) : events_(events), input_fd_(input_fd), output_fd_(output_fd), saver_(std::move(saver)) { undo_history_.reserve(history_limit); redo_history_.reserve(history_limit); state_.today = current_day(); state_.selected_day = state_.today; state_.visible_month = state_.today.year() / state_.today.month(); + state_.week_start = week_start.ok() ? week_start : std::chrono::Monday; synchronize_calendars(); } diff --git a/tests/cli_week_start_test.sh b/tests/cli_week_start_test.sh new file mode 100644 index 0000000..00b9518 --- /dev/null +++ b/tests/cli_week_start_test.sh @@ -0,0 +1,98 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ]; then + echo "usage: cli_week_start_test.sh /path/to/nocal" >&2 + exit 2 +fi + +nocal=$1 +tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/nocal-cli-week-start.XXXXXX") +trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM + +fail() +{ + echo "$1" >&2 + exit 1 +} + +expect_failure() +{ + description=$1 + shift + if "$@" >"$tmpdir/out" 2>"$tmpdir/err"; then + fail "$description unexpectedly succeeded" + fi +} + +help=$("$nocal" --help) +case $help in + *"--week-start DAY"*"monday through sunday"*) ;; + *) fail "help does not document --week-start and its accepted range" ;; +esac + +"$nocal" --print --demo >"$tmpdir/default" +"$nocal" --print --demo --week-start monday >"$tmpdir/monday" +cmp "$tmpdir/default" "$tmpdir/monday" >/dev/null || + fail "default frame is not Monday-first" + +header=$(sed -n '2p' "$tmpdir/monday" | tr -d ' ') +case $header in + *MondayTuesdayWednesdayThursdayFridaySaturdaySunday*) ;; + *) fail "Monday-first header is not in weekday order" ;; +esac + +"$nocal" --print --demo --week-start sunday >"$tmpdir/sunday" +header=$(sed -n '2p' "$tmpdir/sunday" | tr -d ' ') +case $header in + *SundayMondayTuesdayWednesdayThursdayFridaySaturday*) ;; + *) fail "Sunday-first header was not rotated" ;; +esac + +"$nocal" --print --demo --week-start wednesday >"$tmpdir/wednesday" +header=$(sed -n '2p' "$tmpdir/wednesday" | tr -d ' ') +case $header in + *WednesdayThursdayFridaySaturdaySundayMondayTuesday*) ;; + *) fail "Wednesday-first header was not rotated" ;; +esac + +# The first numbered cell must be the correct spill date for each selected +# week start. Nocal is Linux-only, so GNU date is available in its test setup. +month_start=$(date +%Y-%m-01) +month_weekday=$(date -d "$month_start" +%w) +for selection in sunday:0 wednesday:3; do + name=${selection%%:*} + weekday=${selection##*:} + offset=$(( (month_weekday - weekday + 7) % 7 )) + expected=$(date -d "$month_start -$offset days" +%-d) + first_row=$(sed -n '4p' "$tmpdir/$name") + actual=$(printf '%s\n' "$first_row" | sed 's/[^0-9]/ /g' | awk '{print $1}') + [ "$actual" = "$expected" ] || + fail "$name-first grid began on $actual instead of spill date $expected" +done + +# Option parsing finishes before the calendar is loaded or any write mode can +# run. Invalid, missing, and repeated values therefore leave the target exact. +calendar=$tmpdir/calendar.ics +printf '%s\n' 'sentinel calendar bytes' >"$calendar" +cp "$calendar" "$tmpdir/calendar-before" + +expect_failure "invalid week-start value" \ + "$nocal" --calendar "$calendar" --week-start MONDAY +grep -F -- "invalid --week-start day" "$tmpdir/err" >/dev/null || + fail "invalid week-start value did not report its parse error" + +expect_failure "missing week-start value" \ + "$nocal" --calendar "$calendar" --week-start +grep -F -- "--week-start requires a day" "$tmpdir/err" >/dev/null || + fail "missing week-start value did not report its parse error" + +expect_failure "repeated week-start option" \ + "$nocal" --calendar "$calendar" --week-start sunday --week-start monday +grep -F -- "--week-start may only be supplied once" "$tmpdir/err" >/dev/null || + fail "repeated week-start did not report its parse error" + +cmp "$calendar" "$tmpdir/calendar-before" >/dev/null || + fail "week-start parse failure changed the calendar" +[ ! -e "$calendar.bak" ] || + fail "week-start parse failure created a calendar backup" diff --git a/tests/domain_tests.cpp b/tests/domain_tests.cpp index e1017ed..3e875ba 100644 --- a/tests/domain_tests.cpp +++ b/tests/domain_tests.cpp @@ -48,8 +48,27 @@ void test_dates() "Monday has index zero"); check(nocal::monday_first_weekday(nocal::Date{2026y / July / 12d}) == 6, "Sunday has index six"); + const nocal::Date wednesday{2026y / July / 1d}; + for (unsigned start = 0; start < 7; ++start) { + const weekday week_start{start}; + check(nocal::weekday_index(wednesday, week_start) == + (Wednesday.c_encoding() + 7U - start) % 7U, + "weekday index is relative to each possible week start"); + } + check(nocal::weekday_index(wednesday, Monday) == + nocal::monday_first_weekday(wednesday), + "Monday weekday wrapper preserves configurable-index behavior"); check_throws([] { (void)nocal::parse_date("2023-02-29"); }, "invalid date is rejected"); + check_throws( + [] { + (void)nocal::weekday_index( + nocal::Date{2023y / February / 29d}, Monday); + }, + "weekday index rejects an invalid date"); + check_throws( + [wednesday] { (void)nocal::weekday_index(wednesday, weekday{8}); }, + "weekday index rejects an invalid week start"); } void test_month_layout() @@ -57,6 +76,8 @@ void test_month_layout() using namespace std::chrono; const auto layout = nocal::make_month_layout(2026y / July); check(layout.cells.size() == 42, "month layout always has 42 cells"); + check(layout.week_start == Monday, + "default month layout remains Monday-first"); check(layout.first_visible_date() == nocal::Date{2026y / June / 29d}, "July 2026 begins after Monday-leading spill cells"); check(layout.last_visible_date() == nocal::Date{2026y / August / 9d}, @@ -68,6 +89,50 @@ void test_month_layout() const auto* july_17 = layout.find(nocal::Date{2026y / July / 17d}); check(july_17 != nullptr && july_17->row == 2 && july_17->column == 4, "date lookup finds the correct Friday cell"); + + const auto sunday_layout = nocal::make_month_layout(2026y / July, Sunday); + check(sunday_layout.week_start == Sunday, + "month layout records its configured week start"); + check(sunday_layout.first_visible_date() == + nocal::Date{2026y / June / 28d} && + sunday_layout.last_visible_date() == + nocal::Date{2026y / August / 8d}, + "Sunday-first July 2026 has the expected spill dates"); + const auto* sunday_july_first = + sunday_layout.find(nocal::Date{2026y / July / 1d}); + check(sunday_july_first != nullptr && sunday_july_first->row == 0 && + sunday_july_first->column == 3, + "Sunday-first July 2026 places Wednesday in column three"); + + for (unsigned start = 0; start < 7; ++start) { + const weekday week_start{start}; + const auto configured = + nocal::make_month_layout(2026y / July, week_start); + check(configured.week_start == week_start, + "each configured week start is retained by the layout"); + check(weekday{nocal::to_sys_days(configured.first_visible_date())} == + week_start, + "each layout starts on its configured weekday"); + for (std::size_t index = 1; index < configured.cells.size(); ++index) { + check(nocal::to_sys_days(configured.cells[index].date) == + nocal::to_sys_days(configured.cells[index - 1].date) + + days{1}, + "month layout cells are 42 consecutive dates"); + } + const auto* july_first = + configured.find(nocal::Date{2026y / July / 1d}); + check(july_first != nullptr && july_first->row == 0 && + july_first->column == + nocal::weekday_index(nocal::Date{2026y / July / 1d}, + week_start), + "July 2026 column follows the configured week start"); + } + + check_throws( + [] { + (void)nocal::make_month_layout(2026y / July, weekday{8}); + }, + "month layout rejects an invalid week start"); check_throws([&] { (void)layout.at(6, 0); }, "out-of-grid access is rejected"); } diff --git a/tests/tui_tests.cpp b/tests/tui_tests.cpp index 2516cb2..bb36246 100644 --- a/tests/tui_tests.cpp +++ b/tests/tui_tests.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include namespace { @@ -22,6 +24,43 @@ void check(bool condition, const char* message) } } +std::string_view frame_line(const std::string& frame, std::size_t requested) +{ + std::size_t start = 0; + for (std::size_t line = 0; line < requested; ++line) { + const auto newline = frame.find('\n', start); + if (newline == std::string::npos) return {}; + start = newline + 1; + } + const auto newline = frame.find('\n', start); + return std::string_view{frame}.substr( + start, newline == std::string::npos ? frame.size() - start : newline - start); +} + +bool ordered(std::string_view line, const std::vector& labels) +{ + std::size_t position = 0; + for (const auto label : labels) { + position = line.find(label, position); + if (position == std::string_view::npos) return false; + position += label.size(); + } + return true; +} + +nocal::Event all_day_event(std::string uid, std::string title, + nocal::Date start, nocal::Date exclusive_end) +{ + nocal::Event event; + event.uid = std::move(uid); + event.title = std::move(title); + event.start = nocal::make_local_time(start); + event.end = nocal::make_local_time(exclusive_end); + event.all_day = true; + event.time_basis = nocal::TimeBasis::floating; + return event; +} + void test_full_month_render() { using namespace std::chrono; @@ -49,6 +88,7 @@ void test_full_month_render() .agenda_start_day = year{1970} / January / day{1}, .agenda_items = {}, .agenda_index = 0, + .week_start = Monday, }; const std::vector items{ {.id = "1", .title = "Design review", .day = year{2026} / July / day{17}, @@ -105,6 +145,7 @@ void test_compact_and_input() .agenda_start_day = year{1970} / January / day{1}, .agenda_items = {}, .agenda_index = 0, + .week_start = Monday, }; const auto frame = nocal::tui::render_month(std::span{}, state); check(frame.find("July 2026") != std::string::npos, "compact mode keeps month context"); @@ -115,6 +156,109 @@ void test_compact_and_input() "c opens the calendar picker"); } +void test_sunday_start_full_render_and_domain_window() +{ + using namespace std::chrono; + auto state = nocal::tui::ScreenState{}; + state.visible_month = year{2026} / July; + state.selected_day = year{2026} / July / day{1}; + state.today = year{2026} / July / day{17}; + state.width = 140; + state.height = 34; + state.ansi = false; + state.week_start = Sunday; + + const std::vector events{ + all_day_event("leading", "Leading Sunday", year{2026} / June / day{28}, + year{2026} / June / day{29}), + all_day_event("trailing", "Trailing Saturday", year{2026} / August / day{8}, + year{2026} / August / day{9}), + all_day_event("outside", "Outside Saturday", year{2026} / June / day{27}, + year{2026} / June / day{28}), + }; + + const auto frame = nocal::tui::render_month(events, state); + const auto header = frame_line(frame, 1); + check(ordered(header, {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", + "Friday", "Saturday"}), + "Sunday start rotates the full weekday header"); + check(frame_line(frame, 3).starts_with("│ 28"), + "Sunday start places the June 28 spill day in the first cell"); + check(frame.find("Leading Sunday") != std::string::npos && + frame.find("Trailing") != std::string::npos, + "domain adapter includes events at both ends of the configured 42-day grid"); + check(frame.find("Outside Saturday") == std::string::npos, + "domain adapter excludes an event before the configured grid"); + check(frame.find("\x1b[") == std::string::npos, + "Sunday-start full rendering supports ANSI-off output"); + check(std::count(frame.begin(), frame.end(), '\n') == 33, + "Sunday-start full rendering preserves exact requested height"); +} + +void test_wednesday_start_compact_render() +{ + using namespace std::chrono; + auto state = nocal::tui::ScreenState{}; + state.visible_month = year{2026} / July; + state.selected_day = year{2026} / July / day{1}; + state.today = year{2026} / July / day{17}; + state.width = 30; + state.height = 12; + state.ansi = false; + state.week_start = Wednesday; + const std::vector items{ + {.id = "trailing", .title = "Last spill", .day = year{2026} / August / day{11}, + .time_of_day = std::nullopt, .all_day = true, + .end_time_of_day = std::nullopt, .start_day = {}, .end_day = {}, + .location = {}, .description = {}, .detail_title = {}, + .detail_all_day = true, .detail_start_time_of_day = std::nullopt, + .segment = nocal::tui::ItemSegment::single, .recurring = false, + .recurrence_summary = {}, .source_time_zone = {}}, + }; + + const auto frame = nocal::tui::render_month(items, state); + check(ordered(frame_line(frame, 1), {"We", "Th", "Fr", "Sa", "Su", "Mo", "Tu"}), + "Wednesday start rotates the compact weekday header"); + check(frame_line(frame, 2).starts_with(" 1 "), + "Wednesday start places July 1 in the first compact cell"); + check(frame_line(frame, 7).find("11•") != std::string_view::npos, + "compact rendering marks an event in the final spill cell"); + check(frame.find("\x1b[") == std::string::npos, + "Wednesday-start compact rendering supports ANSI-off output"); + check(std::count(frame.begin(), frame.end(), '\n') == 11, + "Wednesday-start compact rendering preserves exact requested height"); +} + +void test_monday_default_and_invalid_week_start_fallback() +{ + using namespace std::chrono; + auto default_state = nocal::tui::ScreenState{}; + default_state.visible_month = year{2026} / July; + default_state.selected_day = year{2026} / July / day{17}; + default_state.today = default_state.selected_day; + default_state.width = 72; + default_state.height = 20; + default_state.ansi = false; + + auto explicit_monday = default_state; + explicit_monday.week_start = Monday; + const auto default_frame = nocal::tui::render_month( + std::span{}, default_state); + const auto explicit_frame = nocal::tui::render_month( + std::span{}, explicit_monday); + check(default_frame == explicit_frame, + "default week start remains byte-compatible with explicit Monday"); + check(ordered(frame_line(default_frame, 1), {"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"}), + "default full header remains Monday-first"); + + auto invalid = default_state; + invalid.week_start = weekday{8}; + const auto invalid_frame = nocal::tui::render_month( + std::span{}, invalid); + check(invalid_frame == default_frame, + "invalid week-start state safely falls back to Monday"); +} + void test_calendar_picker_frame() { using namespace std::chrono; @@ -293,6 +437,9 @@ int main() { test_full_month_render(); test_compact_and_input(); + test_sunday_start_full_render_and_domain_window(); + test_wednesday_start_compact_render(); + test_monday_default_and_invalid_week_start_fallback(); test_navigation(); test_search_frames(); test_calendar_picker_frame();