From b4c7c6f5f45af96f90b15e6fcef0b61aca6b217b Mon Sep 17 00:00:00 2001 From: Sebastian Sanitz Date: Sat, 17 Oct 2026 14:31:51 +0200 Subject: [PATCH] fix(calendar): refetch visible range after creating recurring event When creating a recurring event, the calendar store previously only added the base event returned by the server to its state. The server-expanded recurrence instances (synthetic IDs) were not loaded until the next manual page refresh or visible range query. Refetch the active visible range immediately after creating an event when recurrenceRules are present, matching the behavior used when mutating recurring occurrences. --- ...endar-store-create-recurring-event.test.ts | 82 +++++++++++++++++++ stores/calendar-store.ts | 3 + 2 files changed, 85 insertions(+) create mode 100644 stores/__tests__/calendar-store-create-recurring-event.test.ts diff --git a/stores/__tests__/calendar-store-create-recurring-event.test.ts b/stores/__tests__/calendar-store-create-recurring-event.test.ts new file mode 100644 index 000000000..ef7c726f3 --- /dev/null +++ b/stores/__tests__/calendar-store-create-recurring-event.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useCalendarStore } from '../calendar-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import { createRecurrenceRule } from '@/lib/recurrence-rule'; + +const calendars = [ + { id: 'cal-1', originalId: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true } }, +] as unknown as Calendar[]; + +function fakeClient(overrides: Partial> = {}): IJMAPClient { + return { + createCalendarEvent: vi.fn().mockImplementation(async (event: Partial) => ({ + id: 'ev-created-1', + ...event, + created: '2026-09-29T21:00:00Z', + updated: '2026-09-29T21:00:00Z', + })), + updateCalendarEvent: vi.fn().mockResolvedValue(undefined), + deleteCalendarEvent: vi.fn().mockResolvedValue(undefined), + queryAllCalendarEvents: vi.fn().mockResolvedValue([]), + ...overrides, + } as never; +} + +describe('calendar-store createEvent with recurring rules', () => { + beforeEach(() => { + useCalendarStore.getState().clearState(); + useCalendarStore.setState({ events: [], calendars, selectedCalendarIds: ['cal-1'], error: null }); + }); + + it('creates an event with weekly recurrence rules and stores it', async () => { + const client = fakeClient(); + const eventPayload: Partial = { + title: 'Weekly Sync', + start: '2026-09-30T10:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + showWithoutTime: false, + calendarIds: { 'cal-1': true }, + recurrenceRules: [createRecurrenceRule('weekly')], + }; + + const created = await useCalendarStore.getState().createEvent(client, eventPayload); + + expect(created).not.toBeNull(); + expect(created?.title).toBe('Weekly Sync'); + expect(client.createCalendarEvent).toHaveBeenCalledTimes(1); + const passedEvent = (client.createCalendarEvent as ReturnType).mock.calls[0][0]; + expect(passedEvent.recurrenceRules).toHaveLength(1); + expect(passedEvent.recurrenceRules[0].frequency).toBe('weekly'); + }); + + it('refetches the visible range after creating a recurring event so all occurrences appear immediately', async () => { + const client = fakeClient({ + queryAllCalendarEvents: vi.fn().mockResolvedValue([ + { id: 'ev-created-1', title: 'Daily Standup', start: '2026-09-30T10:00:00', duration: 'PT30M', calendarIds: { 'cal-1': true } }, + { id: 'ev-created-1-occ-1', title: 'Daily Standup', start: '2026-10-01T10:00:00', duration: 'PT30M', calendarIds: { 'cal-1': true } }, + ]), + }); + + // Simulate viewing calendar + await useCalendarStore.getState().fetchEvents(client, '2026-09-01T00:00:00Z', '2026-10-31T23:59:59Z'); + expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(1); + + const recurringEvent: Partial = { + title: 'Daily Standup', + start: '2026-09-30T10:00:00', + duration: 'PT30M', + timeZone: 'Europe/Berlin', + calendarIds: { 'cal-1': true }, + recurrenceRules: [createRecurrenceRule('daily')], + }; + + await useCalendarStore.getState().createEvent(client, recurringEvent); + + // After creating a recurring event, the visible range must be refetched + expect(client.queryAllCalendarEvents).toHaveBeenCalledTimes(2); + expect(useCalendarStore.getState().events).toHaveLength(2); + }); +}); + diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 8efe1f660..ba96cafd3 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -910,6 +910,9 @@ export const useCalendarStore = create()( set((state) => ({ events: [...state.events, mappedCreated] })); void calendarHooks.onAfterEventCreate.emit(eventToView(mappedCreated)); + if (mappedCreated.recurrenceRules && mappedCreated.recurrenceRules.length > 0) { + await refetchAfterOccurrenceMutation(); + } // Invitation emails are sent by the server: `sendSchedulingMessages` // on CalendarEvent/set makes Stalwart queue the iTIP REQUEST itself. // Sending a client-side iMIP copy here produced duplicate emails.