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

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{},
});
}
}

View File

@@ -46,20 +46,12 @@ std::size_t complete_sequence_length(const std::string& input) {
return 1;
}
std::string event_identity(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::size_t source_index(const std::span<const Event> events, const Event& event) {
const auto found = std::find_if(events.begin(), events.end(), [&event](const Event& candidate) {
return &candidate == &event;
});
return static_cast<std::size_t>(std::distance(events.begin(), found));
std::optional<std::size_t> 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;
}
return std::nullopt;
}
} // namespace
@@ -77,15 +69,23 @@ TuiApp::TuiApp(std::vector<Event>& events, SaveCallback saver, const int input_f
state_.visible_month = state_.today.year() / state_.today.month();
}
std::optional<std::size_t> TuiApp::focused_event_index() const {
std::optional<EventOccurrence> TuiApp::focused_occurrence() const {
if (!state_.focused_event_id) return std::nullopt;
const auto event_span = std::span<const Event>{events_};
for (std::size_t index = 0; index < events_.size(); ++index) {
if (event_identity(event_span, index) == *state_.focused_event_id) return index;
for (const auto& occurrence : occurrences_on_day(event_span, state_.selected_day)) {
if (occurrence_focus_id(event_span, occurrence) == *state_.focused_event_id) {
return occurrence;
}
}
return std::nullopt;
}
std::optional<std::size_t> TuiApp::focused_event_index() const {
const auto occurrence = focused_occurrence();
return occurrence ? source_index(std::span<const Event>{events_}, occurrence->source)
: std::nullopt;
}
void TuiApp::set_notification(std::string message, const bool error) {
state_.notification = std::move(message);
state_.notification_is_error = error;
@@ -190,8 +190,13 @@ void TuiApp::begin_edit() {
set_notification("Focus an appointment before editing.", true);
return;
}
try {
editor_.emplace(events_[*index]);
} catch (const std::exception& error) {
set_notification("Cannot edit this appointment: " + std::string{error.what()}, true);
return;
}
pending_mutation_before_ = capture_history_snapshot();
editor_.emplace(events_[*index]);
editing_index_ = index;
state_.show_event_details = false;
state_.show_help = false;
@@ -240,11 +245,33 @@ void TuiApp::submit_editor() {
editor_.reset();
editing_index_.reset();
state_.selected_day = local_date(events_[changed_index].start);
state_.visible_month = state_.selected_day.year() / state_.selected_day.month();
state_.focused_event_id = event_identity(std::span<const Event>{events_}, changed_index);
const auto event_span = std::span<const Event>{events_};
auto preferred = occurrences_on_day(event_span, state_.selected_day);
const auto matching = std::find_if(preferred.begin(), preferred.end(), [&](const auto& value) {
const auto index = source_index(event_span, value.source);
return index && *index == changed_index;
});
if (matching != preferred.end()) {
state_.focused_event_id = occurrence_focus_id(event_span, *matching);
} else {
state_.selected_day = local_date(events_[changed_index].start);
state_.visible_month = state_.selected_day.year() / state_.selected_day.month();
preferred = occurrences_on_day(event_span, state_.selected_day);
const auto first = std::find_if(preferred.begin(), preferred.end(), [&](const auto& value) {
const auto index = source_index(event_span, value.source);
return index && *index == changed_index;
});
state_.focused_event_id = first == preferred.end()
? std::nullopt
: std::optional{occurrence_focus_id(event_span, *first)};
}
state_.show_event_details = false;
record_mutation(editing ? "edit appointment" : "add appointment");
record_mutation(editing && events_[changed_index].recurrence
? "edit recurring series"
: editing ? "edit appointment" : "add appointment");
if (editing && events_[changed_index].recurrence) {
success = "Recurring series updated.";
}
set_notification(std::move(success));
}
@@ -259,6 +286,7 @@ void TuiApp::confirm_delete() {
auto original = events_;
const auto original_focus = state_.focused_event_id;
const bool recurring = events_[*index].recurrence.has_value();
events_.erase(events_.begin() + static_cast<std::ptrdiff_t>(*index));
if (!persist(std::span<const Event>{events_})) {
events_.swap(original);
@@ -271,8 +299,8 @@ void TuiApp::confirm_delete() {
state_.focused_event_id.reset();
state_.show_event_details = false;
state_.confirm_delete = false;
record_mutation("delete appointment");
set_notification("Appointment deleted.");
record_mutation(recurring ? "delete recurring series" : "delete appointment");
set_notification(recurring ? "Recurring series deleted." : "Appointment deleted.");
}
void TuiApp::cancel_delete() {
@@ -347,7 +375,7 @@ void TuiApp::dispatch(const Action action) {
};
const auto cycle_event = [this](const int direction) {
const auto event_span = std::span<const Event>{events_};
const auto day_events = events_on_day(event_span, state_.selected_day);
const auto day_events = occurrences_on_day(event_span, state_.selected_day);
if (day_events.empty()) {
state_.focused_event_id.reset();
return;
@@ -356,8 +384,8 @@ void TuiApp::dispatch(const Action action) {
std::size_t current = day_events.size();
if (state_.focused_event_id) {
for (std::size_t index = 0; index < day_events.size(); ++index) {
const auto event_index = source_index(event_span, day_events[index].get());
if (event_identity(event_span, event_index) == *state_.focused_event_id) {
if (occurrence_focus_id(event_span, day_events[index]) ==
*state_.focused_event_id) {
current = index;
break;
}
@@ -372,8 +400,7 @@ void TuiApp::dispatch(const Action action) {
} else {
target = (current + 1) % day_events.size();
}
const auto event_index = source_index(event_span, day_events[target].get());
state_.focused_event_id = event_identity(event_span, event_index);
state_.focused_event_id = occurrence_focus_id(event_span, day_events[target]);
};
if (state_.show_event_details) {