From ff25ccca6598971da19bae238924b6df9135a2fe Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:16:19 -0400 Subject: [PATCH] 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. --- backend/open_webui/utils/calendar.py | 8 ++-- .../calendar/CalendarEventModal.svelte | 46 ++++++++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/backend/open_webui/utils/calendar.py b/backend/open_webui/utils/calendar.py index 9484c58dc0..e3feb972f0 100644 --- a/backend/open_webui/utils/calendar.py +++ b/backend/open_webui/utils/calendar.py @@ -36,14 +36,16 @@ def expand_recurring_event( range_end_dt = datetime.fromtimestamp(range_end_ns / 1_000_000_000) 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: - # Parse with dtstart near the range so we never iterate from epoch - rule = rrulestr(rrule_str, dtstart=scan_start, ignoretz=True) + # Anchor to the event's real start so day-of-week / day-of-month are correct + rule = rrulestr(rrule_str, dtstart=original_start_dt, ignoretz=True) except Exception: log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}') return [event_dict] - original_start_ns = event_dict['start_at'] original_end_ns = event_dict.get('end_at') duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None diff --git a/src/lib/components/calendar/CalendarEventModal.svelte b/src/lib/components/calendar/CalendarEventModal.svelte index e2e160f747..4e58567dd0 100644 --- a/src/lib/components/calendar/CalendarEventModal.svelte +++ b/src/lib/components/calendar/CalendarEventModal.svelte @@ -33,9 +33,31 @@ let allDay = false; let location = ''; let alertMinutes: number = 10; + let repeatFrequency = ''; let loading = false; let showDeleteConfirmDialog = false; + const REPEAT_RRULE_MAP: Record = { + 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; function nsToDateStr(ns: number): string { @@ -66,6 +88,7 @@ allDay = event.all_day; location = event.location || ''; alertMinutes = event.meta?.alert_minutes ?? 10; + repeatFrequency = parseRepeatFromRrule(event.rrule); } else { title = ''; description = ''; @@ -87,6 +110,7 @@ allDay = false; location = ''; alertMinutes = 10; + repeatFrequency = ''; } } @@ -107,11 +131,12 @@ const result = await updateCalendarEvent(localStorage.token, event.id, { calendar_id: calendarId, title: title.trim(), - description: description.trim() || null, + description: description.trim() || undefined, start_at: startNs, end_at: endNs, all_day: allDay, - location: location.trim() || null, + rrule: getRepeatRrule(), + location: location.trim() || undefined, meta: { alert_minutes: alertMinutes } }); if (result) { @@ -127,6 +152,7 @@ start_at: startNs, end_at: endNs, all_day: allDay, + rrule: getRepeatRrule(), location: location.trim() || undefined, meta: { alert_minutes: alertMinutes } }; @@ -238,6 +264,22 @@ + +
+
{$i18n.t('Repeat')}
+ +
+
{$i18n.t('Description')}