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

@@ -66,10 +66,41 @@ std::pair<unsigned, unsigned> local_hour_minute(const TimePoint point)
return {static_cast<unsigned>(value.tm_hour), static_cast<unsigned>(value.tm_min)};
}
std::string format_time(const TimePoint point)
struct CivilMoment {
Date date;
unsigned hour;
unsigned minute;
};
CivilMoment event_civil_moment(const Event& event, const TimePoint point)
{
const auto [hour, minute] = local_hour_minute(point);
return two_digits(hour) + ':' + two_digits(minute);
if (event.time_basis != TimeBasis::zoned || event.time_zone.empty()) {
const auto [hour, minute] = local_hour_minute(point);
return {local_date(point), hour, minute};
}
const auto* zone = std::chrono::locate_zone(event.time_zone);
const auto local = zone->to_local(point);
const auto local_day = std::chrono::floor<std::chrono::days>(local);
const std::chrono::year_month_day date{local_day};
const auto time = std::chrono::duration_cast<std::chrono::minutes>(local - local_day);
const auto hour = static_cast<unsigned>(time.count() / 60);
const auto minute = static_cast<unsigned>(time.count() % 60);
return {date, hour, minute};
}
TimePoint make_event_time(const Event& event, const Date date, const unsigned hour,
const unsigned minute)
{
if (event.time_basis != TimeBasis::zoned || event.time_zone.empty()) {
return make_local_time(date, hour, minute);
}
const auto* zone = std::chrono::locate_zone(event.time_zone);
const auto value = std::chrono::local_days{date} + std::chrono::hours{hour} +
std::chrono::minutes{minute};
if (zone->get_info(value).result == std::chrono::local_info::nonexistent) {
throw std::runtime_error("local date-time does not exist in source time zone");
}
return zone->to_sys(value, std::chrono::choose::earliest);
}
bool parse_time(const std::string_view text, unsigned& hour, unsigned& minute) noexcept
@@ -240,11 +271,14 @@ EventEditor::EventEditor(const Date selected_day)
EventEditor::EventEditor(const Event& event)
: original_{event}, result_{event}, all_day_{event.all_day}, editing_{true}
{
const auto start = event_civil_moment(event, event.start);
const auto end = event_civil_moment(event, event.end);
values_[index_of(EditorField::title)] = event.title;
values_[index_of(EditorField::start_date)] = format_date(local_date(event.start));
values_[index_of(EditorField::start_time)] = format_time(event.start);
values_[index_of(EditorField::end_date)] = format_date(local_date(event.end));
values_[index_of(EditorField::end_time)] = format_time(event.end);
values_[index_of(EditorField::start_date)] = format_date(start.date);
values_[index_of(EditorField::start_time)] = two_digits(start.hour) + ':' +
two_digits(start.minute);
values_[index_of(EditorField::end_date)] = format_date(end.date);
values_[index_of(EditorField::end_time)] = two_digits(end.hour) + ':' + two_digits(end.minute);
values_[index_of(EditorField::location)] = event.location;
values_[index_of(EditorField::notes)] = event.description;
}
@@ -340,6 +374,10 @@ bool EventEditor::validate()
Event candidate = original_;
if (candidate.uid.empty()) candidate.uid = make_uid();
if (candidate.recurrence && all_day_ != candidate.all_day) {
return fail(EditorField::all_day,
"Timed/all-day conversion is unavailable for recurring series.");
}
candidate.title = title;
candidate.location = values_[index_of(EditorField::location)];
candidate.description = values_[index_of(EditorField::notes)];
@@ -351,8 +389,12 @@ bool EventEditor::validate()
return fail(EditorField::end_date,
"All-day end is exclusive and must be at least the next day.");
}
candidate.start = local_day_start(start_day);
candidate.end = local_day_start(end_day);
// DATE values have no timezone in iCalendar. Normalizing here keeps
// the in-memory result identical to a save/reload round trip.
candidate.time_basis = TimeBasis::floating;
candidate.time_zone.clear();
candidate.start = make_event_time(candidate, start_day, 0, 0);
candidate.end = make_event_time(candidate, end_day, 0, 0);
} else {
unsigned start_hour = 0;
unsigned start_minute = 0;
@@ -364,8 +406,8 @@ bool EventEditor::validate()
if (!parse_time(values_[index_of(EditorField::end_time)], end_hour, end_minute)) {
return fail(EditorField::end_time, "End time must be a valid HH:MM time.");
}
candidate.start = make_local_time(start_day, start_hour, start_minute);
candidate.end = make_local_time(end_day, end_hour, end_minute);
candidate.start = make_event_time(candidate, start_day, start_hour, start_minute);
candidate.end = make_event_time(candidate, end_day, end_hour, end_minute);
if (candidate.end <= candidate.start) {
return fail(EditorField::end_time, "End must be after start.");
}
@@ -393,11 +435,18 @@ std::string EventEditor::render(const int requested_width, const int requested_h
std::vector<std::string> lines;
lines.reserve(16);
lines.push_back(top);
lines.push_back(styled_interior_line(editing_ ? " NOCAL · EDIT APPOINTMENT"
: " NOCAL · NEW APPOINTMENT",
width, "1", ansi));
const auto heading = editing_ && original_.recurrence
? " NOCAL · EDIT ENTIRE RECURRING SERIES"
: editing_ ? " NOCAL · EDIT APPOINTMENT"
: " NOCAL · NEW APPOINTMENT";
lines.push_back(styled_interior_line(heading, width, "1", ansi));
lines.push_back(styled_interior_line(" Tab/↓ next Shift-Tab/↑ back Space toggle",
width, "2", ansi));
lines.push_back(styled_interior_line(
original_.time_basis == TimeBasis::zoned && !original_.time_zone.empty()
? " Times shown in source zone: " + original_.time_zone
: " Times shown in your local zone",
width, "2", ansi));
lines.push_back(rule);
for (std::size_t index = 0; index < field_count; ++index) {
@@ -433,7 +482,7 @@ std::string EventEditor::render(const int requested_width, const int requested_h
compact.push_back(top);
if (height > 2) {
const int available = height - 2;
const int selected_line = 4 + static_cast<int>(index_of(focused_));
const int selected_line = 5 + static_cast<int>(index_of(focused_));
const int maximum_start = static_cast<int>(lines.size()) - 1 - available;
const int start = std::clamp(selected_line - available / 2, 1,
std::max(1, maximum_start));