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

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