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:
@@ -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:
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ future sync workers
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<MonthCell, cell_count> 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
|
||||
|
||||
@@ -89,6 +89,7 @@ struct ScreenState {
|
||||
std::chrono::year{1970}, std::chrono::month{1}, std::chrono::day{1}};
|
||||
std::vector<AgendaItem> 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
|
||||
|
||||
@@ -26,8 +26,12 @@ public:
|
||||
using SaveCallback = std::function<void(std::span<const Event>)>;
|
||||
|
||||
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,
|
||||
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();
|
||||
void dispatch(Action action);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<long long>(index)});
|
||||
|
||||
41
src/main.cpp
41
src/main.cpp
@@ -26,6 +26,7 @@ struct Options {
|
||||
std::filesystem::path calendar_path;
|
||||
std::optional<std::filesystem::path> import_path;
|
||||
std::optional<std::filesystem::path> 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<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 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<nocal::Event> demo_events()
|
||||
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 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_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';
|
||||
|
||||
@@ -196,12 +196,21 @@ std::string border(const std::vector<int>& 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<std::size_t>(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<const CalendarItem> 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<std::string> lines;
|
||||
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()));
|
||||
@@ -726,7 +735,8 @@ std::string compact_frame(std::span<const CalendarItem> 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<const CalendarItem> 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<sys_days, std::vector<const CalendarItem*>> by_day;
|
||||
for (const auto& item : items) {
|
||||
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 << ' ';
|
||||
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<const Event> events,
|
||||
if (state.show_calendar_picker) return calendar_picker_frame(calendars, state);
|
||||
std::vector<CalendarItem> 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}),
|
||||
|
||||
@@ -78,16 +78,26 @@ bool is_search_text(const std::string_view input) {
|
||||
} // namespace
|
||||
|
||||
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,
|
||||
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)) {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
98
tests/cli_week_start_test.sh
Normal file
98
tests/cli_week_start_test.sh
Normal 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"
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
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()
|
||||
{
|
||||
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<nocal::tui::CalendarItem> 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<const nocal::tui::CalendarItem>{}, 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<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()
|
||||
{
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user