Fix YEARLY recurring CalDAV events only showing on DTSTART year (#179)
* Fix YEARLY recurring CalDAV events only showing on DTSTART year (#170) Recurring events with RRULE:FREQ=YEARLY only appeared in the calendar on the year matching DTSTART, not in subsequent years. The list_events query filtered by , which excludes recurring events whose original dtend (e.g. 2019-07-22) falls before the requested window (e.g. 2026). Fix: split the query into two branches — non-recurring events still require window overlap, but recurring events (with non-empty RRULE) are fetched by dtstart < end_dt alone. A new helper, _expand_rrule_occurrences(), uses dateutil.rrule to expand each recurring event into individual occurrence dicts within the requested date range, so YEARLY/WEEKLY/MONTHLY events render correctly across all years. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * recurrence: compound UIDs, frontend fixes, python-dateutil req, tests - Replace _expand_rrule_occurrences with _expand_rrule that emits stable compound UIDs ({base_uid}::{date_or_datetime}) so the frontend can distinguish occurrences from the same series. Non-recurring events pass through with is_recurrence=false and series_uid=uid. - Add _resolve_base_uid() to extract the base series UID from compound UIDs — used by PUT/DELETE /api/calendar/events/{uid} and the manage_calendar tool so edits/deletes always target the base row. - Update manage_calendar tool to import and use _resolve_base_uid. - Frontend _updateEvent / _deleteEvent: detect compound UIDs and invalidate localStorage cache after success so stale sibling occurrences aren't shown. - Add python-dateutil to requirements.txt as an explicit dependency. - Add 14 regression tests in tests/test_calendar_recurrence.py covering _resolve_base_uid edge cases, _expand_rrule with yearly/weekly/monthly/all-day/bad-rrule, unique UIDs, and metadata inheritance. - Merge upstream's cleaner SQLAlchemy or_/and_ query pattern. * recurrence: overlapping malformed-RRULE, exclusive end, multi-day crossings Fix three edge cases in _expand_rrule: 1. Malformed-RRULE fallback now checks window overlap. list_events fetches recurring rows with only dtstart < end_dt, so a broken old recurring event could appear in unrelated future windows. Now fallback returns [] unless the base event's dtstart/dtend actually intersect [start, end). 2. Exclusive end boundary. rule.between(start, end, inc=True) was inclusive on end, but the route contract and non-recurring SQL filter both use [start, end). Added occ_start >= end guard. 3. Multi-day crossings. A recurring occurrence that starts before the window but ends inside it was missed (only occ_start was checked). Now expands from start - duration and filters by occ_start < end AND occ_end > start, matching non-recurring overlap behavior. Tests: +4 tests for these cases (18 total) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -265,12 +265,22 @@ async function _updateEvent(uid, data) {
|
||||
const merged = { ...(_allEvents[uid] || {}), ...data };
|
||||
const _preMergeBackup = _allEvents[uid];
|
||||
_allEvents[uid] = _optimisticEvent(merged, uid);
|
||||
// For recurring events the uid is a compound "{base_uid}::{date}" —
|
||||
// the backend resolves it to the base series row. After the update,
|
||||
// other occurrences of the same series are stale. Wipe the cache so
|
||||
// a re-fetch picks up fresh data (next render + prefetch handles it).
|
||||
const isRecurring = uid.includes('::');
|
||||
fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, {
|
||||
method: 'PUT', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
||||
}).then(r => {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
_saveCache && _saveCache();
|
||||
if (isRecurring) {
|
||||
_fetchedRanges = [];
|
||||
localStorage.removeItem(LS_KEY);
|
||||
} else {
|
||||
_saveCache && _saveCache();
|
||||
}
|
||||
}).catch((e) => {
|
||||
if (_preMergeBackup) _allEvents[uid] = _preMergeBackup;
|
||||
else delete _allEvents[uid];
|
||||
@@ -283,11 +293,17 @@ async function _updateEvent(uid, data) {
|
||||
async function _deleteEvent(uid) {
|
||||
const backup = _allEvents[uid];
|
||||
delete _allEvents[uid];
|
||||
const isRecurring = uid.includes('::');
|
||||
fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, {
|
||||
method: 'DELETE', credentials: 'same-origin',
|
||||
}).then(r => {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
_saveCache && _saveCache();
|
||||
if (isRecurring) {
|
||||
_fetchedRanges = [];
|
||||
localStorage.removeItem(LS_KEY);
|
||||
} else {
|
||||
_saveCache && _saveCache();
|
||||
}
|
||||
}).catch((e) => {
|
||||
if (backup) _allEvents[uid] = backup;
|
||||
if (window.uiModule) window.uiModule.showError('Failed to delete event: ' + (e?.message || 'unknown'));
|
||||
|
||||
Reference in New Issue
Block a user