feat: add recurrence and time zone support
This commit is contained in:
@@ -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{},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user