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

@@ -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");
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");
}

View File

@@ -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();