feat: add recurrence and time zone support

This commit is contained in:
2026-07-17 21:52:02 +01:00
parent 22c6399056
commit c19098004e
18 changed files with 2321 additions and 132 deletions

View File

@@ -6,6 +6,7 @@
#include <ctime>
#include <map>
#include <sstream>
#include <stdexcept>
#include <vector>
namespace nocal::tui {
@@ -157,8 +158,15 @@ std::string iso_date(const year_month_day day) {
std::string event_label(const CalendarItem& item, const int width) {
const auto title = item.title.empty() ? std::string{"(untitled)"} : printable_text(item.title);
std::string segment;
switch (item.segment) {
case ItemSegment::single: break;
case ItemSegment::start: segment = ""; break;
case ItemSegment::continuation: segment = ""; break;
case ItemSegment::end: segment = ""; break;
}
if (item.all_day || !item.time_of_day) {
return fit_text("" + title, width);
return fit_text((segment.empty() ? "" : segment) + title, width);
}
const auto total = item.time_of_day->count();
const auto hour = static_cast<unsigned>((total / 60 + 24) % 24);
@@ -166,7 +174,7 @@ std::string event_label(const CalendarItem& item, const int width) {
if (width < 8) {
return fit_text(title, width);
}
return fit_text(two_digits(hour) + ":" + two_digits(minute) + " " + title, width);
return fit_text(two_digits(hour) + ":" + two_digits(minute) + " " + segment + title, width);
}
std::string event_label(const CalendarItem& item, const int width, const bool focused) {
@@ -338,7 +346,7 @@ std::string detail_frame(std::span<const CalendarItem> items, const ScreenState&
const auto add = [&](std::string text, std::string style = {}) {
content.emplace_back(fit_text(text, inner, false), std::move(style));
};
add(" APPOINTMENT", "2;1");
add(focused.recurring ? " RECURRING APPOINTMENT" : " APPOINTMENT", "2;1");
const auto& title = focused.detail_title.empty() ? focused.title : focused.detail_title;
add(" " + (title.empty() ? std::string{"(untitled)"} : printable_text(title)), "1");
add("");
@@ -359,6 +367,17 @@ std::string detail_frame(std::span<const CalendarItem> items, const ScreenState&
add(" Time " + value + " local");
}
if (!focused.location.empty()) add(" Location " + printable_text(focused.location));
if (focused.recurring) {
add(" Repeats " + (focused.recurrence_summary.empty()
? std::string{"Recurring series"}
: printable_text(focused.recurrence_summary)));
if (!focused.source_time_zone.empty()) {
add(" Source TZ " + printable_text(focused.source_time_zone));
}
add(" Series Edit and delete affect every occurrence", "1;33");
} else if (!focused.source_time_zone.empty()) {
add(" Source TZ " + printable_text(focused.source_time_zone));
}
add("");
if (!focused.description.empty()) {
add(" NOTES", "2;1");
@@ -402,7 +421,10 @@ std::string delete_confirmation_frame(const ScreenState& state, const CalendarIt
const auto& detail_title = focused.detail_title.empty() ? focused.title : focused.detail_title;
const auto title = detail_title.empty() ? std::string{"(untitled)"}
: printable_text(detail_title);
const auto question = fit_text(" Delete “" + title + "”?", inner);
const auto question = fit_text(focused.recurring
? " Delete entire recurring series “" + title + "”?"
: " Delete “" + title + "”?",
inner);
const auto controls = fit_text(" y confirm n/Esc cancel", inner);
const int question_line = std::max(1, height / 2 - 1);
std::ostringstream output;
@@ -437,6 +459,60 @@ LocalMoment to_local(const system_clock::time_point point) {
};
}
std::string recurrence_summary(const RecurrenceRule& rule) {
std::string unit;
switch (rule.frequency) {
case RecurrenceFrequency::daily: unit = rule.interval == 1 ? "day" : "days"; break;
case RecurrenceFrequency::weekly: unit = rule.interval == 1 ? "week" : "weeks"; break;
case RecurrenceFrequency::monthly: unit = rule.interval == 1 ? "month" : "months"; break;
case RecurrenceFrequency::yearly: unit = rule.interval == 1 ? "year" : "years"; break;
}
auto result = rule.interval == 1 ? "Every " + unit
: "Every " + std::to_string(rule.interval) + " " + unit;
if (!rule.by_weekday.empty()) {
result += " on ";
for (std::size_t index = 0; index < rule.by_weekday.size(); ++index) {
if (index != 0) result += ", ";
const auto weekday_index = rule.by_weekday[index].iso_encoding() - 1U;
if (weekday_index < weekday_short.size()) result += weekday_short[weekday_index];
}
}
if (!rule.by_month_day.empty()) {
result += " on day ";
for (std::size_t index = 0; index < rule.by_month_day.size(); ++index) {
if (index != 0) result += ", ";
result += std::to_string(rule.by_month_day[index]);
}
}
if (!rule.by_month.empty()) {
result += " in ";
for (std::size_t index = 0; index < rule.by_month.size(); ++index) {
if (index != 0) result += ", ";
const auto month = rule.by_month[index];
result += month > 0 && month <= month_names.size()
? std::string{month_names[month - 1U]} : std::to_string(month);
}
}
if (rule.count) result += ", " + std::to_string(*rule.count) + " occurrences";
return result;
}
std::size_t event_source_index(const std::span<const Event> events, const Event* source) {
for (std::size_t index = 0; index < events.size(); ++index) {
if (&events[index] == source) return index;
}
throw std::invalid_argument("occurrence source is outside the event collection");
}
std::string source_focus_id(const std::span<const Event> events, const std::size_t index) {
const auto& uid = events[index].uid;
const bool unique = !uid.empty() &&
std::count_if(events.begin(), events.end(), [&uid](const Event& event) {
return event.uid == uid;
}) == 1;
return unique ? uid : "@nocal:" + std::to_string(index);
}
std::string compact_frame(std::span<const CalendarItem> items, const ScreenState& state) {
const int width = std::max(1, state.width);
const int height = std::max(1, state.height);
@@ -515,6 +591,17 @@ std::string compact_frame(std::span<const CalendarItem> items, const ScreenState
} // namespace
std::string occurrence_focus_id(const std::span<const Event> events,
const EventOccurrence& occurrence) {
const auto index = event_source_index(events, occurrence.source);
auto identity = source_focus_id(events, index);
if (occurrence.source->recurrence) {
const auto tick = occurrence.start.time_since_epoch().count();
identity += "@occ:" + std::to_string(tick);
}
return identity;
}
Action decode_key(const std::string_view sequence) noexcept {
if (sequence == "h" || sequence == "\x1b[D" || sequence == "\x1bOD") return Action::left;
if (sequence == "l" || sequence == "\x1b[C" || sequence == "\x1bOC") return Action::right;
@@ -681,22 +768,22 @@ std::string render_month(std::span<const CalendarItem> items, const ScreenState&
std::string render_month(const std::span<const Event> events, const ScreenState& state) {
std::vector<CalendarItem> items;
items.reserve(events.size() * 2);
std::map<std::string, std::size_t> uid_counts;
for (const auto& event : events) {
if (!event.uid.empty()) ++uid_counts[event.uid];
}
const auto visible_start = monday_on_or_before(sys_days{state.visible_month / day{1}});
const auto visible_end = visible_start + days{41};
for (std::size_t index = 0; index < events.size(); ++index) {
const auto& event = events[index];
const auto identity = !event.uid.empty() && uid_counts[event.uid] == 1
? event.uid : "@nocal:" + std::to_string(index);
const auto local_start = to_local(event.start);
const auto local_finish = to_local(event.end);
const auto occurrences = occurrences_overlapping(
events, local_day_start(year_month_day{visible_start}),
local_day_start(year_month_day{visible_end + days{1}}));
for (const auto& occurrence : occurrences) {
if (occurrence.source == nullptr) continue;
const auto& event = *occurrence.source;
const auto identity = occurrence_focus_id(events, occurrence);
const auto local_start = to_local(occurrence.start);
const auto local_finish = to_local(occurrence.end);
const auto one_second = duration_cast<system_clock::duration>(seconds{1});
const auto final_instant = event.end > event.start
? event.end - std::min(event.end - event.start, one_second)
: event.start;
const auto final_instant = occurrence.end > occurrence.start
? occurrence.end -
std::min(occurrence.end - occurrence.start, one_second)
: occurrence.start;
const auto local_end = to_local(final_instant);
const auto event_start = sys_days{local_start.date};
const auto event_last = sys_days{local_end.date};
@@ -704,9 +791,15 @@ std::string render_month(const std::span<const Event> events, const ScreenState&
const auto last_day = std::min(event_last, visible_end);
for (auto date = first_day; date <= last_day; date += days{1}) {
const bool continuation = date != event_start;
ItemSegment segment = ItemSegment::single;
if (event_start != event_last) {
if (date == event_start) segment = ItemSegment::start;
else if (date == event_last) segment = ItemSegment::end;
else segment = ItemSegment::continuation;
}
items.push_back(CalendarItem{
.id = identity,
.title = continuation ? "" + event.title : event.title,
.title = event.title,
.day = year_month_day{date},
.time_of_day = event.all_day || continuation
? std::nullopt
@@ -723,6 +816,12 @@ std::string render_month(const std::span<const Event> events, const ScreenState&
.detail_start_time_of_day = event.all_day
? std::nullopt
: std::optional{local_start.time_of_day},
.segment = segment,
.recurring = event.recurrence.has_value(),
.recurrence_summary = event.recurrence
? recurrence_summary(*event.recurrence) : std::string{},
.source_time_zone = event.time_basis == TimeBasis::zoned
? event.time_zone : std::string{},
});
}
}