feat: add repeat/recurrence dropdown to calendar event modal (#25865)
* feat(calendar): add repeat/recurrence dropdown to event modal * fix(calendar): anchor recurring event expansion to event start date The expand_recurring_event() utility used the view range start as dtstart, causing FREQ=WEEKLY events to land on the wrong day of the week. Use the event's original start date instead so recurrence patterns stay correct.
This commit is contained in:
2 files changed
+49
-5
No files matched your search
@@ -36,14 +36,16 @@ def expand_recurring_event(
|
|||||||
range_end_dt = datetime.fromtimestamp(range_end_ns / 1_000_000_000)
|
range_end_dt = datetime.fromtimestamp(range_end_ns / 1_000_000_000)
|
||||||
scan_start = range_start_dt - timedelta(days=1)
|
scan_start = range_start_dt - timedelta(days=1)
|
||||||
|
|
||||||
|
original_start_ns = event_dict['start_at']
|
||||||
|
original_start_dt = datetime.fromtimestamp(original_start_ns / 1_000_000_000)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse with dtstart near the range so we never iterate from epoch
|
# Anchor to the event's real start so day-of-week / day-of-month are correct
|
||||||
rule = rrulestr(rrule_str, dtstart=scan_start, ignoretz=True)
|
rule = rrulestr(rrule_str, dtstart=original_start_dt, ignoretz=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}')
|
log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}')
|
||||||
return [event_dict]
|
return [event_dict]
|
||||||
|
|
||||||
original_start_ns = event_dict['start_at']
|
|
||||||
original_end_ns = event_dict.get('end_at')
|
original_end_ns = event_dict.get('end_at')
|
||||||
duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None
|
duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,31 @@
|
|||||||
let allDay = false;
|
let allDay = false;
|
||||||
let location = '';
|
let location = '';
|
||||||
let alertMinutes: number = 10;
|
let alertMinutes: number = 10;
|
||||||
|
let repeatFrequency = '';
|
||||||
let loading = false;
|
let loading = false;
|
||||||
let showDeleteConfirmDialog = false;
|
let showDeleteConfirmDialog = false;
|
||||||
|
|
||||||
|
const REPEAT_RRULE_MAP: Record<string, string> = {
|
||||||
|
daily: 'FREQ=DAILY',
|
||||||
|
weekdays: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR',
|
||||||
|
weekly: 'FREQ=WEEKLY',
|
||||||
|
monthly: 'FREQ=MONTHLY',
|
||||||
|
yearly: 'FREQ=YEARLY'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRepeatRrule(): string | undefined {
|
||||||
|
return REPEAT_RRULE_MAP[repeatFrequency] || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRepeatFromRrule(rrule: string | null): string {
|
||||||
|
if (!rrule) return '';
|
||||||
|
const normalized = rrule.toUpperCase().replace(/\s/g, '');
|
||||||
|
for (const [key, value] of Object.entries(REPEAT_RRULE_MAP)) {
|
||||||
|
if (normalized === value) return key;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
const NS = 1_000_000;
|
const NS = 1_000_000;
|
||||||
|
|
||||||
function nsToDateStr(ns: number): string {
|
function nsToDateStr(ns: number): string {
|
||||||
@@ -66,6 +88,7 @@
|
|||||||
allDay = event.all_day;
|
allDay = event.all_day;
|
||||||
location = event.location || '';
|
location = event.location || '';
|
||||||
alertMinutes = event.meta?.alert_minutes ?? 10;
|
alertMinutes = event.meta?.alert_minutes ?? 10;
|
||||||
|
repeatFrequency = parseRepeatFromRrule(event.rrule);
|
||||||
} else {
|
} else {
|
||||||
title = '';
|
title = '';
|
||||||
description = '';
|
description = '';
|
||||||
@@ -87,6 +110,7 @@
|
|||||||
allDay = false;
|
allDay = false;
|
||||||
location = '';
|
location = '';
|
||||||
alertMinutes = 10;
|
alertMinutes = 10;
|
||||||
|
repeatFrequency = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,11 +131,12 @@
|
|||||||
const result = await updateCalendarEvent(localStorage.token, event.id, {
|
const result = await updateCalendarEvent(localStorage.token, event.id, {
|
||||||
calendar_id: calendarId,
|
calendar_id: calendarId,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || null,
|
description: description.trim() || undefined,
|
||||||
start_at: startNs,
|
start_at: startNs,
|
||||||
end_at: endNs,
|
end_at: endNs,
|
||||||
all_day: allDay,
|
all_day: allDay,
|
||||||
location: location.trim() || null,
|
rrule: getRepeatRrule(),
|
||||||
|
location: location.trim() || undefined,
|
||||||
meta: { alert_minutes: alertMinutes }
|
meta: { alert_minutes: alertMinutes }
|
||||||
});
|
});
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -127,6 +152,7 @@
|
|||||||
start_at: startNs,
|
start_at: startNs,
|
||||||
end_at: endNs,
|
end_at: endNs,
|
||||||
all_day: allDay,
|
all_day: allDay,
|
||||||
|
rrule: getRepeatRrule(),
|
||||||
location: location.trim() || undefined,
|
location: location.trim() || undefined,
|
||||||
meta: { alert_minutes: alertMinutes }
|
meta: { alert_minutes: alertMinutes }
|
||||||
};
|
};
|
||||||
@@ -238,6 +264,22 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Repeat -->
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Repeat')}</div>
|
||||||
|
<select
|
||||||
|
class="w-full text-sm bg-transparent outline-hidden cursor-pointer"
|
||||||
|
bind:value={repeatFrequency}
|
||||||
|
>
|
||||||
|
<option value="">{$i18n.t('No Repeat')}</option>
|
||||||
|
<option value="daily">{$i18n.t('Daily')}</option>
|
||||||
|
<option value="weekdays">{$i18n.t('Monday – Friday')}</option>
|
||||||
|
<option value="weekly">{$i18n.t('Weekly')}</option>
|
||||||
|
<option value="monthly">{$i18n.t('Monthly')}</option>
|
||||||
|
<option value="yearly">{$i18n.t('Yearly')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
|
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
|
||||||
|
|||||||
Reference in new issue
Block a user