feat: make month week start configurable

Add a Monday-default --week-start option for every weekday and carry it through domain layout, full and compact rendering, event query windows, print mode, and the interactive TUI.

Preserve byte-compatible Monday output and cover rotated headers, spill dates, invalid input, sanitizer behavior, and live terminal restoration.
This commit is contained in:
2026-07-18 09:58:27 +01:00
parent b524e6e33d
commit 989332ef9a
17 changed files with 430 additions and 23 deletions

View File

@@ -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 `$XDG_DATA_HOME/nocal/calendar.ics` by default (falling back to
`~/.local/share/nocal/calendar.ics`), or accepts another `.ics` path as its `~/.local/share/nocal/calendar.ics`), or accepts another `.ics` path as its
positional argument. `--demo` adds a few unsaved sample appointments and 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: Calendar interchange is explicit, non-interactive, and network-free:

View File

@@ -20,7 +20,11 @@ future sync workers
The domain layer uses C++20 `<chrono>` civil dates and time points. It knows The domain layer uses C++20 `<chrono>` civil dates and time points. It knows
nothing about escape sequences, files, OAuth, or HTTP. Month layout always 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, Events retain a stable UID, title, half-open start/end interval, all-day flag,
optional descriptive fields, and calendar identity. Queries define overlap as optional descriptive fields, and calendar identity. Queries define overlap as

View File

@@ -8,7 +8,8 @@ typeface and palette; Nocal owns hierarchy, spacing, and restrained emphasis.
## Product principles ## Product principles
1. **The month is the home screen.** Launching Nocal immediately shows six 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, 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 ordered as all-day first and then by start time. A final `+N more` line is
used instead of clipping silently. 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 changing the month selection unless an occurrence was chosen. Hidden calendars
remain filtered there, while calendar selection and search can overlay the remain filtered there, while calendar selection and search can overlay the
agenda. Agenda navigation never persists state, and add/edit/delete remain in agenda. Agenda navigation never persists state, and add/edit/delete remain in
the month workflow. The next local-data work adds advanced recurrence overrides the month workflow. Week-start selection rotates only the month grid, header,
and rule editing. 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 ## Local file interchange

View File

@@ -12,6 +12,7 @@
- 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 - 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 - Explicit atomic import/export with validation, collision refusal, and backups
- 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
@@ -19,7 +20,6 @@
## 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
- 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

@@ -31,6 +31,8 @@ using YearMonth = std::chrono::year_month;
[[nodiscard]] Date first_day_of_month(YearMonth month); [[nodiscard]] Date first_day_of_month(YearMonth month);
[[nodiscard]] YearMonth following_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); [[nodiscard]] unsigned monday_first_weekday(Date date);
} // namespace nocal } // namespace nocal

View File

@@ -20,6 +20,7 @@ struct MonthLayout {
static constexpr std::size_t cell_count = rows * columns; static constexpr std::size_t cell_count = rows * columns;
YearMonth month{std::chrono::year{1970} / std::chrono::January}; YearMonth month{std::chrono::year{1970} / std::chrono::January};
std::chrono::weekday week_start{std::chrono::Monday};
std::array<MonthCell, cell_count> cells{}; std::array<MonthCell, cell_count> cells{};
[[nodiscard]] const MonthCell& at(std::size_t row, std::size_t column) const; [[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]] 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 } // namespace nocal

View File

@@ -89,6 +89,7 @@ struct ScreenState {
std::chrono::year{1970}, std::chrono::month{1}, std::chrono::day{1}}; std::chrono::year{1970}, std::chrono::month{1}, std::chrono::day{1}};
std::vector<AgendaItem> agenda_items; std::vector<AgendaItem> agenda_items;
std::size_t agenda_index{0}; 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 // Render a complete frame. The result contains no cursor positioning, which

View File

@@ -26,8 +26,12 @@ public:
using SaveCallback = std::function<void(std::span<const Event>)>; using SaveCallback = std::function<void(std::span<const Event>)>;
explicit TuiApp(std::vector<Event>& events, int input_fd = 0, int output_fd = 1); explicit TuiApp(std::vector<Event>& events, int input_fd = 0, int output_fd = 1);
TuiApp(std::vector<Event>& events, std::chrono::weekday week_start,
int input_fd = 0, int output_fd = 1);
TuiApp(std::vector<Event>& events, SaveCallback saver, int input_fd = 0, TuiApp(std::vector<Event>& events, SaveCallback saver, int input_fd = 0,
int output_fd = 1); int output_fd = 1);
TuiApp(std::vector<Event>& events, SaveCallback saver,
std::chrono::weekday week_start, int input_fd = 0, int output_fd = 1);
[[nodiscard]] ExitReason run(); [[nodiscard]] ExitReason run();
void dispatch(Action action); void dispatch(Action action);

View File

@@ -53,6 +53,8 @@ test('cli-recovery', shell,
args: [files('tests/cli_recovery_test.sh'), nocal_executable]) args: [files('tests/cli_recovery_test.sh'), nocal_executable])
test('cli-transfer', shell, test('cli-transfer', shell,
args: [files('tests/cli_transfer_test.sh'), nocal_executable]) 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_data('scripts/nocal-hyprland', install_dir: get_option('bindir'),
install_mode: 'rwxr-xr-x') install_mode: 'rwxr-xr-x')

View File

@@ -163,10 +163,20 @@ YearMonth following_month(YearMonth month)
return month + std::chrono::months{1}; 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) unsigned monday_first_weekday(Date date)
{ {
const auto weekday = std::chrono::weekday{to_sys_days(date)}.c_encoding(); return weekday_index(date, std::chrono::Monday);
return (weekday + 6U) % 7U;
} }
} // namespace nocal } // namespace nocal

View File

@@ -32,14 +32,19 @@ Date MonthLayout::last_visible_date() const noexcept
return cells.back().date; 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 first = first_day_of_month(month);
const auto grid_start = to_sys_days(first) - 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; MonthLayout result;
result.month = month; result.month = month;
result.week_start = week_start;
for (std::size_t index = 0; index < result.cells.size(); ++index) { for (std::size_t index = 0; index < result.cells.size(); ++index) {
const auto date = from_sys_days(grid_start + const auto date = from_sys_days(grid_start +
std::chrono::days{static_cast<long long>(index)}); std::chrono::days{static_cast<long long>(index)});

View File

@@ -26,6 +26,7 @@ struct Options {
std::filesystem::path calendar_path; std::filesystem::path calendar_path;
std::optional<std::filesystem::path> import_path; std::optional<std::filesystem::path> import_path;
std::optional<std::filesystem::path> export_path; std::optional<std::filesystem::path> export_path;
std::chrono::weekday week_start{std::chrono::Monday};
bool demo{false}; bool demo{false};
bool print{false}; bool print{false};
bool restore_backup{false}; bool restore_backup{false};
@@ -50,6 +51,7 @@ void print_help(std::ostream& output)
"Usage: nocal [OPTIONS] [CALENDAR.ics]\n\n" "Usage: nocal [OPTIONS] [CALENDAR.ics]\n\n"
"A theme-native terminal month calendar.\n\n" "A theme-native terminal month calendar.\n\n"
" -c, --calendar PATH read events from PATH\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" " --demo add sample appointments without saving them\n"
" --print print one plain-text frame and exit\n" " --print print one plain-text frame and exit\n"
" --restore-backup restore PATH.bak over PATH 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"; " t jumps to today, ? toggles help, q quits.\n";
} }
std::optional<std::chrono::weekday> 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 parse_options(int argc, char** argv)
{ {
Options options{ Options options{
.calendar_path = default_calendar_path(), .calendar_path = default_calendar_path(),
.import_path = std::nullopt, .import_path = std::nullopt,
.export_path = std::nullopt, .export_path = std::nullopt,
.week_start = std::chrono::Monday,
.demo = false, .demo = false,
.print = false, .print = false,
.restore_backup = false, .restore_backup = false,
.no_color = false, .no_color = false,
}; };
bool positional_seen = false; bool positional_seen = false;
bool week_start_seen = false;
for (int index = 1; index < argc; ++index) { for (int index = 1; index < argc; ++index) {
const std::string_view argument{argv[index]}; const std::string_view argument{argv[index]};
if (argument == "-h" || argument == "--help") { if (argument == "-h" || argument == "--help") {
@@ -98,6 +114,22 @@ Options parse_options(int argc, char** argv)
options.restore_backup = true; options.restore_backup = true;
} else if (argument == "--no-color") { } else if (argument == "--no-color") {
options.no_color = true; 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") { } else if (argument == "--import" || argument == "--export") {
if (++index >= argc) { if (++index >= argc) {
throw std::invalid_argument(std::string{argument} + " requires a path"); throw std::invalid_argument(std::string{argument} + " requires a path");
@@ -182,7 +214,8 @@ std::vector<nocal::Event> demo_events()
return {std::move(planning), std::move(release), std::move(coffee)}; return {std::move(planning), std::move(release), std::move(coffee)};
} }
int print_frame(std::span<const nocal::Event> events, bool no_color) int print_frame(std::span<const nocal::Event> events, bool no_color,
const std::chrono::weekday week_start)
{ {
const auto day = nocal::today(); const auto day = nocal::today();
const nocal::tui::ScreenState state{ const nocal::tui::ScreenState state{
@@ -205,6 +238,7 @@ int print_frame(std::span<const nocal::Event> events, bool no_color)
.agenda_start_day = day, .agenda_start_day = day,
.agenda_items = {}, .agenda_items = {},
.agenda_index = 0, .agenda_index = 0,
.week_start = week_start,
}; };
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;
@@ -304,7 +338,7 @@ int main(int argc, char** argv)
const bool non_interactive = ::isatty(STDIN_FILENO) == 0 || const bool non_interactive = ::isatty(STDIN_FILENO) == 0 ||
::isatty(STDOUT_FILENO) == 0; ::isatty(STDOUT_FILENO) == 0;
if (options.print || non_interactive) { if (options.print || non_interactive) {
return print_frame(loaded.events, true); return print_frame(loaded.events, true, options.week_start);
} }
if (options.no_color) { if (options.no_color) {
::setenv("NO_COLOR", "1", 1); ::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; return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS;
} catch (const std::exception& error) { } catch (const std::exception& error) {
std::cerr << "nocal: " << error.what() << '\n'; std::cerr << "nocal: " << error.what() << '\n';

View File

@@ -196,12 +196,21 @@ std::string border(const std::vector<int>& widths, const char* left,
return result; return result;
} }
sys_days monday_on_or_before(const sys_days date) { weekday valid_week_start(const weekday requested) {
const weekday day{date}; return requested.ok() ? requested : Monday;
const auto offset = (day.iso_encoding() + 6) % 7; }
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}; 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<std::size_t>(start.iso_encoding() - 1U) + column) % 7U;
}
struct LocalMoment { struct LocalMoment {
year_month_day date; year_month_day date;
minutes time_of_day; minutes time_of_day;
@@ -714,7 +723,7 @@ std::string compact_frame(std::span<const CalendarItem> items, const ScreenState
const int width = std::max(1, state.width); const int width = std::max(1, state.width);
const int height = std::max(1, state.height); const int height = std::max(1, state.height);
const auto first = sys_days{state.visible_month / day{1}}; 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<std::string> lines; std::vector<std::string> lines;
const auto title = std::string{month_names[static_cast<unsigned>(state.visible_month.month()) - 1]} + const auto title = std::string{month_names[static_cast<unsigned>(state.visible_month.month()) - 1]} +
" " + std::to_string(static_cast<int>(state.visible_month.year())); " " + std::to_string(static_cast<int>(state.visible_month.year()));
@@ -726,7 +735,8 @@ std::string compact_frame(std::span<const CalendarItem> items, const ScreenState
} }
std::string weekdays; std::string weekdays;
for (std::size_t column = 0; column < weekday_short.size(); ++column) { 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)); lines.push_back(styled(weekdays, "2;1", state.ansi));
for (int week = 0; week < 6; ++week) { for (int week = 0; week < 6; ++week) {
@@ -853,7 +863,7 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
} }
const auto first = sys_days{state.visible_month / day{1}}; 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<sys_days, std::vector<const CalendarItem*>> by_day; std::map<sys_days, std::vector<const CalendarItem*>> by_day;
for (const auto& item : items) { for (const auto& item : items) {
if (item.day.ok()) { if (item.day.ok()) {
@@ -871,7 +881,9 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
output << styled(centred(title, width), "1", state.ansi) << '\n'; output << styled(centred(title, width), "1", state.ansi) << '\n';
output << ' '; output << ' ';
for (std::size_t column = 0; column < widths.size(); ++column) { 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 << styled(centred(name, widths[column]), "2;1", state.ansi);
output << (column + 1 == widths.size() ? ' ' : ' '); output << (column + 1 == widths.size() ? ' ' : ' ');
} }
@@ -977,7 +989,8 @@ std::string render_month(const std::span<const Event> events,
if (state.show_calendar_picker) return calendar_picker_frame(calendars, state); if (state.show_calendar_picker) return calendar_picker_frame(calendars, state);
std::vector<CalendarItem> items; std::vector<CalendarItem> items;
items.reserve(events.size() * 2); 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 visible_end = visible_start + days{41};
const auto occurrences = occurrences_overlapping( const auto occurrences = occurrences_overlapping(
events, local_day_start(year_month_day{visible_start}), events, local_day_start(year_month_day{visible_start}),

View File

@@ -78,16 +78,26 @@ bool is_search_text(const std::string_view input) {
} // namespace } // namespace
TuiApp::TuiApp(std::vector<Event>& events, const int input_fd, const int output_fd) TuiApp::TuiApp(std::vector<Event>& 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<Event>& 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<Event>& events, SaveCallback saver, const int input_fd, TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver, const int input_fd,
const int output_fd) const int output_fd)
: TuiApp(events, std::move(saver), std::chrono::Monday, input_fd, output_fd) {}
TuiApp::TuiApp(std::vector<Event>& 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)) { : events_(events), input_fd_(input_fd), output_fd_(output_fd), saver_(std::move(saver)) {
undo_history_.reserve(history_limit); undo_history_.reserve(history_limit);
redo_history_.reserve(history_limit); redo_history_.reserve(history_limit);
state_.today = current_day(); state_.today = current_day();
state_.selected_day = state_.today; state_.selected_day = state_.today;
state_.visible_month = state_.today.year() / state_.today.month(); state_.visible_month = state_.today.year() / state_.today.month();
state_.week_start = week_start.ok() ? week_start : std::chrono::Monday;
synchronize_calendars(); synchronize_calendars();
} }

View File

@@ -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"

View File

@@ -48,8 +48,27 @@ void test_dates()
"Monday has index zero"); "Monday has index zero");
check(nocal::monday_first_weekday(nocal::Date{2026y / July / 12d}) == 6, check(nocal::monday_first_weekday(nocal::Date{2026y / July / 12d}) == 6,
"Sunday has index six"); "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"); }, check_throws([] { (void)nocal::parse_date("2023-02-29"); },
"invalid date is rejected"); "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() void test_month_layout()
@@ -57,6 +76,8 @@ void test_month_layout()
using namespace std::chrono; using namespace std::chrono;
const auto layout = nocal::make_month_layout(2026y / July); const auto layout = nocal::make_month_layout(2026y / July);
check(layout.cells.size() == 42, "month layout always has 42 cells"); 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}, check(layout.first_visible_date() == nocal::Date{2026y / June / 29d},
"July 2026 begins after Monday-leading spill cells"); "July 2026 begins after Monday-leading spill cells");
check(layout.last_visible_date() == nocal::Date{2026y / August / 9d}, 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}); const auto* july_17 = layout.find(nocal::Date{2026y / July / 17d});
check(july_17 != nullptr && july_17->row == 2 && july_17->column == 4, check(july_17 != nullptr && july_17->row == 2 && july_17->column == 4,
"date lookup finds the correct Friday cell"); "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"); check_throws([&] { (void)layout.at(6, 0); }, "out-of-grid access is rejected");
} }

View File

@@ -8,6 +8,8 @@
#include <iostream> #include <iostream>
#include <optional> #include <optional>
#include <string> #include <string>
#include <string_view>
#include <utility>
#include <vector> #include <vector>
namespace { 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<std::string_view>& 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() void test_full_month_render()
{ {
using namespace std::chrono; using namespace std::chrono;
@@ -49,6 +88,7 @@ void test_full_month_render()
.agenda_start_day = year{1970} / January / day{1}, .agenda_start_day = year{1970} / January / day{1},
.agenda_items = {}, .agenda_items = {},
.agenda_index = 0, .agenda_index = 0,
.week_start = Monday,
}; };
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},
@@ -105,6 +145,7 @@ void test_compact_and_input()
.agenda_start_day = year{1970} / January / day{1}, .agenda_start_day = year{1970} / January / day{1},
.agenda_items = {}, .agenda_items = {},
.agenda_index = 0, .agenda_index = 0,
.week_start = Monday,
}; };
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");
@@ -115,6 +156,109 @@ void test_compact_and_input()
"c opens the calendar picker"); "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<nocal::Event> 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<nocal::tui::CalendarItem> 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<const nocal::tui::CalendarItem>{}, default_state);
const auto explicit_frame = nocal::tui::render_month(
std::span<const nocal::tui::CalendarItem>{}, 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<const nocal::tui::CalendarItem>{}, invalid);
check(invalid_frame == default_frame,
"invalid week-start state safely falls back to Monday");
}
void test_calendar_picker_frame() void test_calendar_picker_frame()
{ {
using namespace std::chrono; using namespace std::chrono;
@@ -293,6 +437,9 @@ int main()
{ {
test_full_month_render(); test_full_month_render();
test_compact_and_input(); 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_navigation();
test_search_frames(); test_search_frames();
test_calendar_picker_frame(); test_calendar_picker_frame();