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

@@ -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) {