diff --git a/components/email/__tests__/calendar-invitation-banner.test.tsx b/components/email/__tests__/calendar-invitation-banner.test.tsx index 5b39b83a7..1665f8ecb 100644 --- a/components/email/__tests__/calendar-invitation-banner.test.tsx +++ b/components/email/__tests__/calendar-invitation-banner.test.tsx @@ -758,6 +758,56 @@ describe('CalendarInvitationBanner', () => { expect(send).toBe(true); }); + // A proposal for one occurrence of a recurring meeting names it by its + // recurrence id; it went to the first event with the uid, any occurrence + // in view. + describe('a proposal for a recurring meeting', () => { + const participants = { + organizer: { '@type': 'Participant', email: 'user@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + attendee: { '@type': 'Participant', email: 'alice@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, + }; + // Two occurrences of a weekly meeting at 09:00 in Berlin, as the server expands them. + const occurrence = (id: string, day: string) => ({ + id, uid: 'uid-7', title: 'Weekly', start: `2026-03-${day}T09:00:00`, duration: 'PT1H', timeZone: 'Europe/Berlin', + recurrenceId: `2026-03-${day}T09:00:00`, recurrenceIdTimeZone: 'Europe/Berlin', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], participants, + }); + const counter = (extra: Record) => ({ + uid: 'uid-7', title: 'Weekly', start: '2026-03-27T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', participants, ...extra, + }); + const openCounter = async (id: string) => { + render(); + await waitFor(() => expect(screen.getByText('Review proposal')).toBeInTheDocument()); + }; + + beforeEach(() => { + mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(undefined); + mocks.calendarState.events = [occurrence('occ-20', '20'), occurrence('occ-27', '27')]; + }); + + it('applies a proposal for one occurrence to that occurrence', async () => { + // Named in UTC: 08:00Z is 09:00 in Berlin in March. + mocks.clientMock.parseCalendarEvents.mockResolvedValue([counter({ recurrenceId: '2026-03-27T08:00:00', recurrenceIdTimeZone: 'Etc/UTC' })]); + + await openCounter('email-7'); + fireEvent.click(screen.getByText('Apply proposed changes')); + await waitFor(() => expect(mocks.calendarState.updateEvent).toHaveBeenCalledTimes(1)); + + const [, id, patch] = mocks.calendarState.updateEvent.mock.calls[0]; + expect(id).toBe('occ-27'); + expect(patch).toMatchObject({ start: '2026-03-27T10:00:00' }); + }); + + it('does not apply a proposal for the whole meeting to one of its occurrences', async () => { + mocks.clientMock.parseCalendarEvents.mockResolvedValue([counter({})]); + + await openCounter('email-8'); + + expect(screen.queryByText('Apply proposed changes')).toBeNull(); + expect(mocks.calendarState.updateEvent).not.toHaveBeenCalled(); + }); + }); + it('keeps the answers when an applied proposal does not change the time', async () => { mocks.calendarState.updateEvent = vi.fn().mockResolvedValue(undefined); const event = (room: string) => ({ diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 92e90a222..51e37bbb7 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -38,6 +38,7 @@ import { type InvitationTrustAssessment, } from '@/lib/calendar-invitation'; import { participantsAskedAgain } from '@/lib/calendar-participants'; +import { isValidTimeZone, moveLocalDateTime } from '@/lib/timezone'; import { cn } from '@/lib/utils'; import { sanitizeColor } from '@/components/calendar/event-card'; import { RecipientPopover } from './recipient-popover'; @@ -208,6 +209,31 @@ function getViewActionLabel( return t('view_in_calendar'); } +/** Whether two events name the same occurrence: their recurrence ids at the same moment, each in its own zone. */ +function sameRecurrenceId(a: Partial, b: Partial): boolean { + if (!a.recurrenceId || !b.recurrenceId) return false; + const zoneA = a.recurrenceIdTimeZone ?? a.timeZone ?? null; + const zoneB = b.recurrenceIdTimeZone ?? b.timeZone ?? null; + const bInA = zoneA && zoneB && isValidTimeZone(zoneA) && isValidTimeZone(zoneB) + ? moveLocalDateTime(b.recurrenceId.slice(0, 19), zoneB, zoneA) + : b.recurrenceId; + return a.recurrenceId.slice(0, 19) === bInA.slice(0, 19); +} + +/** + * The event in the calendar a counter proposal is about. An attendee's + * proposal for one occurrence of a recurring meeting names that occurrence + * by its recurrence id, one for the whole meeting names none. Matched on the + * uid alone, the first event with it got the proposal - any occurrence in + * view. A proposal for a whole series of which only occurrences are in view + * has no event here, and is not applied to one of them. + */ +function counterTarget(events: CalendarEvent[], proposal: Partial): CalendarEvent | null { + const sameUid = events.filter((event) => event.uid === proposal.uid); + if (!proposal.recurrenceId) return sameUid.find((event) => !event.recurrenceId) ?? null; + return sameUid.find((event) => sameRecurrenceId(event, proposal)) ?? null; +} + function buildProposalPatch( currentEvent: Partial | null, proposedEvent: Partial | null, @@ -553,7 +579,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // the import-then-find-participant flow for this case. const canRespond = supportsCalendar && allowsRsvp && !isResponseOnly && !userIsOrganizer && (Boolean(myParticipant) || method === 'request'); - const proposalPatch = existingEvent && parsedEvent ? buildProposalPatch(existingEvent, parsedEvent) : null; + // A counter proposal applies to the occurrence it names, or to the meeting itself. + const proposalTarget = method === 'counter' && parsedEvent ? counterTarget(storeEvents, parsedEvent) : existingEvent; + const proposalPatch = proposalTarget && parsedEvent ? buildProposalPatch(proposalTarget, parsedEvent) : null; const resolveExistingEventForRsvp = async () => { if (!client || !existingEvent) { @@ -723,12 +751,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp }); }; - const proposedChanges = method === 'counter' && existingEvent && parsedEvent - ? buildInvitationChangeItems(t, existingEvent, parsedEvent, formatDateTime) + const proposedChanges = method === 'counter' && proposalTarget && parsedEvent + ? buildInvitationChangeItems(t, proposalTarget, parsedEvent, formatDateTime) : []; const canApplyProposal = Boolean( client - && existingEvent?.id + && proposalTarget?.id && userIsOrganizer && method === 'counter' && proposalPatch @@ -736,7 +764,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const viewActionLabel = getViewActionLabel(t, method, userIsOrganizer); const handleApplyProposal = async () => { - if (!client || !existingEvent?.id || !proposalPatch || isProcessing) { + if (!client || !proposalTarget?.id || !proposalPatch || isProcessing) { return; } @@ -748,8 +776,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp // A proposal for another time moves the event: its attendees are asked // again, as the editor and a drag ask them (participantsAskedAgain). const moves = (['start', 'duration', 'timeZone', 'showWithoutTime'] as const).some((key) => key in proposalPatch); - const participants = moves ? participantsAskedAgain(existingEvent) : null; - await updateEvent(client, existingEvent.id, participants ? { ...proposalPatch, participants } : proposalPatch, true); + const participants = moves ? participantsAskedAgain(proposalTarget) : null; + await updateEvent(client, proposalTarget.id, participants ? { ...proposalPatch, participants } : proposalPatch, true); setActionNotice(t('proposal_applied')); setState('parsed'); setRsvpStatus(null); diff --git a/lib/__tests__/recurrence-scope-edit.test.ts b/lib/__tests__/recurrence-scope-edit.test.ts index 9cb443e0d..b80271483 100644 --- a/lib/__tests__/recurrence-scope-edit.test.ts +++ b/lib/__tests__/recurrence-scope-edit.test.ts @@ -12,6 +12,7 @@ import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { getEventEndDate, getEventStartDate } from '@/lib/calendar-utils'; import { AUTO_TIME_ZONE } from '@/lib/timezone'; import { useSettingsStore } from '@/stores/settings-store'; +import { useCalendarStore } from '@/stores/calendar-store'; import type { CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; @@ -513,11 +514,215 @@ describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('overrides }); }); +// The move of an "all" edit was measured on the user's clock. In the week +// Berlin leaves summer time and New York does not yet, 16:00 on Saturday and +// 16:00 on Monday in Berlin are 10:00 and 11:00 in New York: the series went +// to Mondays at 10:00 instead of the 11:00 the user picked. +describe('changing every occurrence across a week only one zone changes clocks in', () => { + it('moves the series by what the move is on its own clock', () => { + // Saturdays 10:00 in New York from 3 October; the user is in Berlin. + const master = { + ...series('1.0'), start: '2026-10-03T10:00:00', timeZone: 'America/New_York', + utcStart: '2026-10-03T14:00:00Z', utcEnd: '2026-10-03T15:00:00Z', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], recurrenceOverrides: null, + } as unknown as CalendarEvent; + const occurrence = { + ...master, id: 'maaaaac', baseEventId: 'b', start: '2026-10-24T10:00:00', recurrenceId: '2026-10-24T10:00:00', + utcStart: '2026-10-24T14:00:00Z', utcEnd: '2026-10-24T15:00:00Z', + } as CalendarEvent; + const from = getEventStartDate(occurrence); + // 16:00 in Berlin on Saturday 24 October, moved to 16:00 on Monday 26 October. + expect(from.getHours()).toBe(16); + const updates = seriesUpdatesFromOccurrence( + occurrence, master, editorUpdates(occurrence, { start: '2026-10-26T16:00:00', timeZone: 'Europe/Berlin' }), + editorOn(occurrence, { when: { allDay: false, start: addDays(from, 2), end: addHours(addDays(from, 2), 1) } }), + context, + ); + + expect(updates.start).toBe('2026-10-05T11:00:00'); + }); +}); + +// "All" from an occurrence with an override of its own wrote everything the +// editor showed - the occurrence's own title, copied into its override when +// it got one - to the series: a new room there renamed the series back. +describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('what "all" writes from an overridden occurrence on Stalwart %s', (_label, version) => { + const room = (name: string) => ({ loc1: { '@type': 'Location', name } }) as unknown as CalendarEvent['locations']; + // "Daily sync" weekly from 7 December. 21 December is called "Standup" in + // room 1; 28 December was moved to 14:00 on its own (0.16 copied the + // title then, as it was). + function withOwnTitle(): CalendarEvent { + const copies = version === '1.0' ? {} : { duration: 'PT1H', title: 'Daily sync' }; + return { + ...series(version), title: 'Daily sync', start: '2026-12-07T10:00:00', utcStart: '2026-12-07T09:00:00Z', utcEnd: '2026-12-07T10:00:00Z', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], locations: room('Room 0'), + recurrenceOverrides: { + '2026-12-21T10:00:00': version === '1.0' + ? { title: 'Standup', locations: room('Room 1') } + : { start: '2026-12-21T10:00:00', duration: 'PT1H', title: 'Standup', locations: room('Room 1') }, + '2026-12-28T10:00:00': { start: '2026-12-28T14:00:00', ...copies }, + }, + } as unknown as CalendarEvent; + } + const on = (master: CalendarEvent, recurrenceId: string, own: Partial = {}) => ({ + ...master, id: 'maaaaac', baseEventId: 'b', start: recurrenceId, recurrenceId, + utcStart: berlinUtc(recurrenceId), utcEnd: berlinUtc(recurrenceId, 1), ...own, + }) as CalendarEvent; + + it('writes only the new room to the series, and to that occurrence', () => { + const master = withOwnTitle(); + const occurrence = on(master, '2026-12-21T10:00:00', { title: 'Standup', locations: room('Room 1') }); + const updates = seriesUpdatesFromOccurrence( + occurrence, master, editorUpdates(occurrence, { locations: room('Room 2') }), editorOn(occurrence), context, + ); + + expect(updates).not.toHaveProperty('title'); + expect(updates.locations).toEqual(room('Room 2')); + const own = updates.recurrenceOverrides!['2026-12-21T10:00:00'] as Record; + expect(own).toMatchObject({ title: 'Standup', locations: room('Room 2') }); + }); + + // 1.0 returns the field as pointers when the occurrence's value came from + // the editor under another id (live): the room stayed Room 1 there. + it('replaces pointers into the changed field on that occurrence', () => { + const master = withOwnTitle(); + master.recurrenceOverrides!['2026-12-21T10:00:00'] = { + title: 'Standup', 'locations/loc1/name': 'Room 1', 'locations/l': null, + } as never; + const occurrence = on(master, '2026-12-21T10:00:00', { title: 'Standup', locations: room('Room 1') }); + const updates = seriesUpdatesFromOccurrence( + occurrence, master, editorUpdates(occurrence, { locations: room('Room 2') }), editorOn(occurrence), context, + ); + + expect(updates.recurrenceOverrides!['2026-12-21T10:00:00']).toEqual({ title: 'Standup', locations: room('Room 2') }); + }); + + it('renames the series and the occurrences that only had its old name, not one with its own', () => { + const master = withOwnTitle(); + const occurrence = on(master, '2026-12-14T10:00:00'); + const updates = seriesUpdatesFromOccurrence( + occurrence, master, editorUpdates(occurrence, { title: 'Weekly sync' }), editorOn(occurrence), context, + ); + + expect(updates.title).toBe('Weekly sync'); + if (version === '1.0') { + // 1.0 keeps an override as a patch: 28 December has no title to change. + expect(updates).not.toHaveProperty('recurrenceOverrides'); + } else { + const overrides = updates.recurrenceOverrides as Record>; + expect(overrides['2026-12-28T10:00:00'].title).toBe('Weekly sync'); + expect(overrides['2026-12-21T10:00:00'].title).toBe('Standup'); + } + }); +}); + +// "This and following" started the new series at the opened occurrence's own +// time and in the user's zone. Mondays 10:00 in New York, 21 December moved +// alone to Tuesday: renamed from there by a user in Berlin, the rest became +// a Tuesday series at 16:00 in Berlin. +describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('where "this and following" goes on on Stalwart %s', (_label, version) => { + function mondays(): CalendarEvent { + return { + ...series(version), start: '2026-12-07T10:00:00', timeZone: 'America/New_York', + utcStart: '2026-12-07T15:00:00Z', utcEnd: '2026-12-07T16:00:00Z', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], + recurrenceOverrides: { + '2026-12-21T10:00:00': version === '1.0' + ? { start: '2026-12-22T10:00:00', title: 'Planning, Tuesday' } + : { start: '2026-12-22T10:00:00', duration: 'PT1H', title: 'Planning, Tuesday' }, + }, + } as unknown as CalendarEvent; + } + // 21 December as the server hands it out: on Tuesday, 16:00 in Berlin. + const tuesday = (master: CalendarEvent) => ({ + ...master, id: 'maaaaac', baseEventId: 'b', start: '2026-12-22T10:00:00', recurrenceId: '2026-12-21T10:00:00', + recurrenceIdTimeZone: 'America/New_York', title: 'Planning, Tuesday', + utcStart: '2026-12-22T15:00:00Z', utcEnd: '2026-12-22T16:00:00Z', + }) as CalendarEvent; + + async function split(change: Partial, hours = 0) { + const master = mondays(); + const occurrence = tuesday(master); + const deps = actions(master); + await saveOccurrenceEdit('this_and_future', { + event: occurrence, + updates: editorUpdates(occurrence, { start: '2026-12-22T16:00:00', timeZone: 'Europe/Berlin', ...change }), + editor: editorOn(occurrence, hours ? moveBy(occurrence, hours) : {}), + sendScheduling: true, userEmails: [ORGANIZER], + }, deps); + return deps.createEvent.mock.calls[0][1]; + } + + // 0.16.23 turns UNTIL, read on the series' clock, into UTC and compares + // that with the occurrences' own clock: in New York "until 09:59:59" kept + // the 10:00 occurrence (live). Five hours earlier ends the series there on + // 0.16 and on 1.0. + it('ends the old series early enough for 0.16 in a zone behind UTC', async () => { + const master = mondays(); + const deps = actions(master); + + await deleteOccurrence('this_and_future', { event: tuesday(master), sendScheduling: true, userEmails: [ORGANIZER] }, { + ...deps, deleteEvent: vi.fn(), + }); + + const [, , patch] = deps.updateEvent.mock.calls[0]; + expect(patch.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly', until: '2026-12-21T04:59:59', count: null }]); + }); + + it('goes on from the occurrence\'s place in the series, in the series\' zone, the moved occurrence staying where it was', async () => { + const created = await split({ description: 'New agenda' }); + + expect(created).toMatchObject({ start: '2026-12-21T10:00:00', timeZone: 'America/New_York', description: 'New agenda' }); + // The series' title, not the occurrence's own; that one stays with it. + expect(created.title).toBe('Planning'); + expect(created.recurrenceOverrides!['2026-12-21T10:00:00']).toMatchObject({ start: '2026-12-22T10:00:00', title: 'Planning, Tuesday' }); + }); + + it('moves the new series and the occurrence by what the editor moved it', async () => { + const created = await split({}, 1); + + expect(created).toMatchObject({ start: '2026-12-21T11:00:00', timeZone: 'America/New_York' }); + expect(created.recurrenceOverrides!['2026-12-21T11:00:00']).toMatchObject({ start: '2026-12-22T11:00:00' }); + }); +}); + +// With the real calendar store: it answers a create that failed on the way +// ("Failed to fetch") with null. The series stayed ended - told so to the +// attendees - and the save reported success. +describe('this and following when the store cannot create the new series', () => { + beforeEach(() => { + useCalendarStore.getState().clearState(); + }); + + it('puts the series back and fails the save on Stalwart 1.0', async () => { + const occurrence = serverOccurrence('1.0'); + useCalendarStore.setState({ events: [occurrence], calendars: [], error: null }); + const client = { + updateCalendarEvent: vi.fn().mockResolvedValue(undefined), + createCalendarEvent: vi.fn().mockRejectedValue(new Error('Failed to fetch')), + queryAllCalendarEvents: vi.fn().mockResolvedValue([]), + } as unknown as IJMAPClient & { updateCalendarEvent: ReturnType }; + const store = useCalendarStore.getState(); + + await expect(saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence, { title: 'Planning, room 2' }), editor: editorOn(occurrence), + sendScheduling: true, userEmails: [ORGANIZER], + }, { client, updateEvent: store.updateEvent, createEvent: store.createEvent, findMasterEvent: async () => series('1.0') })).rejects.toThrow(); + + expect(client.updateCalendarEvent).toHaveBeenCalledTimes(2); + const [id, restore, send] = client.updateCalendarEvent.mock.calls[1]; + expect([id, send]).toEqual(['b', true]); + expect(restore).toMatchObject({ recurrenceRules: series('1.0').recurrenceRules }); + }); +}); + function actions(master: CalendarEvent | null) { return { client: {} as IJMAPClient, updateEvent: vi.fn().mockResolvedValue(undefined), - createEvent: vi.fn().mockResolvedValue(null), + // The calendar store's createEvent: the created event, or null when the + // server refused it for a reason it does not pass on. + createEvent: vi.fn(async (_client: IJMAPClient, event: Partial, _send?: boolean) => ({ ...event, id: 'n1' }) as CalendarEvent | null), findMasterEvent: vi.fn().mockResolvedValue(master), } satisfies OccurrenceEditActions; } @@ -538,11 +743,9 @@ describe('saveOccurrenceEdit for every occurrence', () => { expect(saved).toBe(true); expect(deps.findMasterEvent).toHaveBeenCalledWith(occurrence); - expect(deps.updateEvent).toHaveBeenCalledWith( - deps.client, 'b', - { title: 'Planning, room 2', description: '', calendarIds: { cal: true }, recurrenceRules: occurrence.recurrenceRules }, - true, - ); + // What the user changed, and only that: the calendar, the description + // and the rule the editor wrote as they were stay the series' own. + expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { title: 'Planning, room 2' }, true); }); it('reports a series it cannot find', async () => { @@ -597,7 +800,28 @@ describe.each(occurrences)('saving this and the following occurrences on Stalwar expect(statusOf(created.participants, ATTENDEE)).toBe('needs-action'); }); - it('puts the series back, telling the attendees again, when the new one cannot be created', async () => { + // The calendar store answers most failures - "Failed to fetch" among them - + // with null and reports them itself. The series stayed ended, told so to + // the attendees, and the save said it had worked. + it('puts the series back, telling the attendees again, when the store could not create the new one', async () => { + const occurrence = make(); + const deps = actions(series(version)); + deps.createEvent.mockResolvedValue(null); + + await expect(saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: true, userEmails: [ORGANIZER], + }, deps)).rejects.toThrow('rest of the series'); + + expect(deps.updateEvent).toHaveBeenCalledTimes(2); + // The whole map: the truncation took the series' only override, and a + // pointer into a series without overrides fails. + expect(deps.updateEvent).toHaveBeenLastCalledWith(deps.client, 'b', { + recurrenceRules: series(version).recurrenceRules, + recurrenceOverrides: series(version).recurrenceOverrides, + }, true); + }); + + it('puts the series back and passes on a refusal the store passes on', async () => { const occurrence = make(); const deps = actions(series(version)); deps.createEvent.mockRejectedValue(new Error('An event cannot have more than 20 participants.')); @@ -606,10 +830,9 @@ describe.each(occurrences)('saving this and the following occurrences on Stalwar event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: true, userEmails: [ORGANIZER], }, deps)).rejects.toThrow('more than 20'); - expect(deps.updateEvent).toHaveBeenLastCalledWith(deps.client, 'b', { + expect(deps.updateEvent).toHaveBeenLastCalledWith(deps.client, 'b', expect.objectContaining({ recurrenceRules: series(version).recurrenceRules, - 'recurrenceOverrides/2026-10-19T10:00:00': series(version).recurrenceOverrides!['2026-10-19T10:00:00'], - }, true); + }), true); }); it('ends an attendee\'s own copy quietly', async () => { @@ -695,7 +918,10 @@ describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the series return { ...actions(master), deleteEvent: vi.fn().mockResolvedValue(undefined) }; } - it('changes only the first occurrence for "this occurrence"', async () => { + // userb accepted the series. Stalwart sends a new override only to + // attendees who have not answered: the change went out in one write that + // reached nobody who had. It now takes the store's two steps. + it('changes only the first occurrence for "this occurrence", in two steps for those who answered', async () => { const master = base(); const deps = seriesActions(master); @@ -704,11 +930,35 @@ describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the series }, deps); expect(saved).toBe(true); - const [, id, patch, send] = deps.updateEvent.mock.calls[0]; - expect([id, send]).toEqual(['b', true]); - // The whole map, as the series has no overrides yet; nothing of the series changes. - expect(Object.keys(patch)).toEqual(['recurrenceOverrides']); - expect(patch.recurrenceOverrides['2026-10-05T10:00:00']).toMatchObject({ start: '2026-10-05T10:00:00', title: 'Kick-off' }); + expect(deps.updateEvent).toHaveBeenCalledTimes(2); + // First the occurrence as it is, without messages: the whole map, as the + // series has no overrides yet; nothing of the series changes. + const [, id, copy, quiet] = deps.updateEvent.mock.calls[0]; + expect([id, quiet]).toEqual(['b', false]); + expect(Object.keys(copy)).toEqual(['recurrenceOverrides']); + expect(copy.recurrenceOverrides['2026-10-05T10:00:00']).toMatchObject({ start: '2026-10-05T10:00:00', title: 'Planning' }); + // Then the change of that override, with messages. + const [, again, change, send] = deps.updateEvent.mock.calls[1]; + expect([again, send]).toEqual(['b', true]); + expect(Object.keys(change)).toEqual(['recurrenceOverrides/2026-10-05T10:00:00']); + expect(change['recurrenceOverrides/2026-10-05T10:00:00']).toMatchObject({ start: '2026-10-05T10:00:00', title: 'Kick-off' }); + expect(statusOf(change['recurrenceOverrides/2026-10-05T10:00:00'].participants, ATTENDEE)).toBe('accepted'); + }); + + it('changes it in one write when nobody has answered, or nothing is sent', async () => { + const unanswered = base({ + participants: { org: participant(version, ORGANIZER, true, 'accepted'), b: participant(version, ATTENDEE, false, 'needs-action') }, + }); + for (const [master, send] of [[unanswered, true], [base(), false]] as const) { + const deps = seriesActions(master); + + await saveOccurrenceEdit('this', { + event: master, updates: editorUpdates(master, { title: 'Kick-off', recurrenceId: undefined }), sendScheduling: send, userEmails: [ORGANIZER], + }, deps); + + expect(deps.updateEvent).toHaveBeenCalledTimes(1); + expect(deps.updateEvent.mock.calls[0][3]).toBe(send); + } }); it('adds the override to those the series has', async () => { @@ -736,7 +986,7 @@ describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the series expect(deps.updateEvent).toHaveBeenCalledTimes(1); const [, id, patch] = deps.updateEvent.mock.calls[0]; expect(id).toBe('b'); - expect(patch).toMatchObject({ title: 'Kick-off', recurrenceRules: master.recurrenceRules }); + expect(patch).toEqual({ title: 'Kick-off' }); }); it('excludes the first occurrence for "delete this occurrence"', async () => { diff --git a/lib/recurrence-instances.ts b/lib/recurrence-instances.ts index 3a4e0f28e..285ec9862 100644 --- a/lib/recurrence-instances.ts +++ b/lib/recurrence-instances.ts @@ -238,7 +238,8 @@ export function resolveOverrideKey( return instance.recurrenceId; } -function stableJson(value: unknown): string { +/** `value` as JSON with the members of every object in a fixed order: equal values, equal strings. */ +export function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; if (value && typeof value === 'object') { const entries = Object.entries(value as Record) diff --git a/lib/recurrence-scope-edit.ts b/lib/recurrence-scope-edit.ts index ed2731baf..a3f17d163 100644 --- a/lib/recurrence-scope-edit.ts +++ b/lib/recurrence-scope-edit.ts @@ -4,7 +4,7 @@ * occurrence, it and the ones after it, or all of them. */ -import { differenceInCalendarDays, format, isSameDay, parseISO } from 'date-fns'; +import { differenceInCalendarDays, isSameDay, parseISO } from 'date-fns'; import type { RecurrenceEditScope } from '@/components/calendar/recurrence-scope-dialog'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { CalendarEvent } from '@/lib/jmap/types'; @@ -12,6 +12,7 @@ import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from '@/li import { parseDurationSeconds } from '@/lib/calendar-event-normalization'; import { getParticipantList, + hasAnsweredAttendees, participantFieldsToSave, participantsAskedAgain, userOrganizes, @@ -20,15 +21,25 @@ import { import { buildFallbackExcludePatch, buildFallbackOverridePatch, + buildOccurrencePatch, + createsOverride, isBrowserExpandedOccurrence, isServerRecurrenceInstance, overrideContextOf, resolveOverrideKey, + stableJson, withNewOverrideDetails, } from '@/lib/recurrence-instances'; import { occurrencesBefore, seriesOccursAt } from '@/lib/recurrence-expansion'; import { sameRecurrenceRules } from '@/lib/recurrence-rule'; -import { getEffectiveTimeZone, moveLocalDateTime } from '@/lib/timezone'; +import { + fromDisplayDate, + getEffectiveTimeZone, + getTimeZoneOffsetMs, + isValidTimeZone, + moveLocalDateTime, + toLocalDateTime, +} from '@/lib/timezone'; import { debug } from '@/lib/debug'; export interface EditorAttendee { @@ -172,16 +183,33 @@ function withParticipantsOf(entry: OverrideEntry, participants: CalendarEvent['p return result; } -/** An override that changes nothing about an occurrence the series has (a PatchObject without a key). */ -function isEmptyOverride(entry: OverrideEntry): boolean { - return Object.keys(entry).length === 0; +/** `date`, a display date in the user's zone, as the wall clock of `timeZone` to the minute, in milliseconds. */ +function zonedMinutesMs(date: Date, timeZone: string): number { + const wall = wallClockMs(toLocalDateTime(fromDisplayDate(date).toISOString(), timeZone)) ?? 0; + return Math.floor(wall / 60_000) * 60_000; +} + +/** + * How far the editor moved an occurrence from `from` to `to` (display dates) + * on the series' own clock: its zone's wall clock when it has one. Measured + * on the user's clock, a move across a week in which only one of the two + * zones changed to or from daylight saving time moved the series by an hour + * more or less than the occurrence: from Saturday 10:00 in New York to + * Monday 11:00 there (16:00 on both days for a user in Berlin) made it + * Mondays at 10:00. + */ +function seriesClockDelta(from: Date, to: Date, series: CalendarEvent): number { + const zone = !series.showWithoutTime && series.timeZone && isValidTimeZone(series.timeZone) ? series.timeZone : null; + return zone + ? zonedMinutesMs(to, zone) - zonedMinutesMs(from, zone) + : wallClockMinutesMs(to) - wallClockMinutesMs(from); } /** * When the series happens after the editor moved one of its occurrences: * its own start moved by as much as the occurrence was, on its own date and - * in its own zone; the length only when the editor changed it. Nothing when - * the occurrence did not move. + * in its own zone (seriesClockDelta); the length only when the editor + * changed it. Nothing when the occurrence did not move. */ function seriesWhen( occurrence: CalendarEvent, @@ -205,7 +233,7 @@ function seriesWhen( result.start = `${shiftLocalDateTime(`${first.slice(0, 10)}T00:00:00`, days * 86_400_000).slice(0, 10)}T00:00:00`; } } else { - const delta = wallClockMinutesMs(when.start) - wallClockMinutesMs(from); + const delta = seriesClockDelta(from, when.start, master); if (delta !== 0 || allDayChanged) result.start = shiftLocalDateTime(master.start, delta); } @@ -220,6 +248,103 @@ function seriesWhen( return result; } +/** The editor's fields a save changes on its own: when (seriesWhen), who (participantFieldsToSave), which occurrence. */ +const NOT_EDITOR_FIELDS = new Set([...WHEN_KEYS, 'recurrenceId', 'participants', 'organizerCalendarAddress', 'replyTo']); + +/** The first location's name, or virtual location's URI, as the editor shows it. */ +const firstOf = (map: Record | null | undefined, field: 'name' | 'uri') => + (Object.values(map ?? {})[0]?.[field] ?? '').trim(); + +/** + * The fields of `updates` - what the editor wrote for `occurrence` - that + * the user changed there, compared the way the editor shows them. The + * editor writes every field it shows, and on an occurrence with an override + * of its own those are the occurrence's: its title, copied when it got the + * override, or a room of its own. Written to the whole series, a new room + * on such an occurrence also gave the series back that occurrence's old + * title. + */ +export function changedEditorFields( + occurrence: CalendarEvent, + updates: Partial, + editor?: Pick, +): Partial { + const ruleChanged = editor ? !!editor.ruleChanged : !sameRecurrenceRules(occurrence.recurrenceRules, updates.recurrenceRules); + const changed: Record = {}; + for (const [key, value] of Object.entries(updates)) { + if (NOT_EDITOR_FIELDS.has(key)) continue; + let differs: boolean; + switch (key) { + case 'title': + differs = (value ?? '') !== (occurrence.title ?? ''); + break; + case 'description': + differs = String(value ?? '').trim() !== (occurrence.description ?? '').trim(); + break; + case 'locations': + differs = firstOf(value as CalendarEvent['locations'], 'name') !== firstOf(occurrence.locations, 'name'); + break; + case 'virtualLocations': + differs = firstOf(value as CalendarEvent['virtualLocations'], 'uri') !== firstOf(occurrence.virtualLocations, 'uri'); + break; + case 'calendarIds': + differs = stableJson(Object.keys(value ?? {}).sort()) !== stableJson(Object.keys(occurrence.calendarIds ?? {}).sort()); + break; + case 'recurrenceRules': + case 'recurrenceOverrides': + case 'excludedRecurrenceRules': + differs = ruleChanged; + break; + default: + differs = stableJson(value) !== stableJson((occurrence as unknown as Record)[key]); + } + if (differs) changed[key] = value; + } + return changed as Partial; +} + +/** + * `entry`, an override of `series`, with the editor's `changes` to the + * series, where it holds a value of the field: the override of the + * occurrence the editor was opened on takes them all - they were made on it + * - any other one where it only held the series' old value (0.16 keeps a + * copy of the title, place and details in every override Bulwark writes: + * without this, a renamed series kept its old name on those occurrences). A + * value an override has of its own stays; one it does not hold comes from + * the series. + */ +function withSeriesChanges(entry: OverrideEntry, changes: Partial, series: CalendarEvent, opened: boolean): OverrideEntry { + const result = { ...entry }; + // Only what one occurrence may carry (buildOccurrencePatch): not the + // rule, the calendar or the default alerts switch. + for (const [key, value] of Object.entries(buildOccurrencePatch(changes))) { + // 1.0 returns an override as a PatchObject: a field of its own can be + // pointers into it (`locations/loc1/name`), which would keep the old + // value on the occurrence the change was made on. + const pointers = Object.keys(result).filter((name) => name.startsWith(`${key}/`)); + if (opened && (key in result || pointers.length > 0)) { + for (const name of pointers) delete result[name]; + result[key] = value; + } else if (!opened && key in result && stableJson(result[key]) === stableJson((series as unknown as Record)[key])) { + result[key] = value; + } + } + return result; +} + +/** + * Whether `entry`, the override under `key`, changes nothing about its + * occurrence in `series`: each value it holds is the series' - its start the + * occurrence's own. The server's bookkeeping in it does not count. + */ +function changesNothing(entry: OverrideEntry, key: string, series: Partial): boolean { + return !entry.excluded && Object.entries(entry).every(([name, value]) => { + if (name === 'sequence' || name === 'updated') return true; + if (name === 'start') return typeof value === 'string' && wallClockMs(value) === wallClockMs(key); + return stableJson(value) === stableJson((series as unknown as Record)[name]); + }); +} + /** An event's attendees as the editor lists them: each address once, without the organizer. */ function attendeesOf(event: CalendarEvent): EditorAttendee[] { return getParticipantList(event) @@ -267,17 +392,21 @@ function seriesParticipants( * occurrence had moves with the series; so does the length. A series * turned all-day or timed takes every occurrence along. When the series' * attendees are asked again, so they are for each occurrence that moves. + * What the user changed in the editor goes into the overrides as well + * (withSeriesChanges). */ function seriesOverrides( occurrence: CalendarEvent, master: CalendarEvent, series: Partial, - options: { ruleChanged: boolean; askAgain: boolean }, + options: { ruleChanged: boolean; askAgain: boolean; changes: Partial }, ): OverrideMap | null | undefined { const overrides = master.recurrenceOverrides; if (!overrides || Object.keys(overrides).length === 0) return undefined; const resized = series.duration !== undefined; - if (series.start === undefined && series.showWithoutTime === undefined && !resized && !options.ruleChanged) return undefined; + const moves = series.start !== undefined || series.showWithoutTime !== undefined; + const changes = Object.keys(buildOccurrencePatch(options.changes)).length > 0; + if (!moves && !resized && !options.ruleChanged && !changes) return undefined; const from: SeriesFrame = { start: master.start, showWithoutTime: !!master.showWithoutTime, recurrenceRules: master.recurrenceRules }; const to: SeriesFrame = { @@ -311,13 +440,24 @@ function seriesOverrides( } } if (options.askAgain && follows) entry = withAnswersAskedAgain(entry, master); - if (isEmptyOverride(entry) && seriesOccursAt(to.start, to.recurrenceRules, target)) continue; + if (changes) entry = withSeriesChanges(entry, options.changes, master, key === opened); + if (seriesOccursAt(to.start, to.recurrenceRules, target) && changesNothing(entry, target, { ...master, ...series })) continue; } result[target] = entry as OverrideMap[string]; } + // Nothing moved and no override took a change: they stay as they are. + if (stableJson(result) === stableJson(withoutUpdated(overrides))) return undefined; return Object.keys(result).length > 0 ? result : null; } +/** Overrides without the server's `updated` stamps, as a rewrite sends them. */ +function withoutUpdated(overrides: OverrideMap): OverrideMap { + return Object.fromEntries(Object.entries(overrides).map(([key, override]) => { + const { updated: _updated, ...rest } = override as OverrideEntry; + return [key, rest]; + })) as OverrideMap; +} + /** * The editor's `updates`, made on `occurrence`, as a change to its whole * series `master`. @@ -327,6 +467,7 @@ function seriesOverrides( * started on the occurrence's day - the occurrences before it gone, a * counted series running longer - and every occurrence got that one * occurrence's attendees and answers. The series keeps its own instead: + * - its details, but for those the user changed (changedEditorFields); * - when it happens, unless the occurrence moved; then the series moves by * as much from its own start (seriesWhen); * - its participants and their answers, with the attendees the user added @@ -341,10 +482,8 @@ export function seriesUpdatesFromOccurrence( editor: EventEditorState, context: Omit, ): Partial { - const series: Partial = { ...updates }; - for (const key of [...WHEN_KEYS, 'recurrenceId', 'participants', 'organizerCalendarAddress', 'replyTo'] as const) { - delete series[key]; - } + const changes = changedEditorFields(occurrence, updates, editor); + const series: Partial = { ...changes }; const when = seriesWhen(occurrence, master, updates, editor.when); Object.assign(series, when); const rescheduled = Object.keys(when).length > 0 || !!editor.ruleChanged; @@ -353,6 +492,7 @@ export function seriesUpdatesFromOccurrence( const overrides = seriesOverrides(occurrence, master, series, { ruleChanged: !!editor.ruleChanged, askAgain: rescheduled && !!participants.participants, + changes, }); if (overrides !== undefined) series.recurrenceOverrides = overrides; return series; @@ -449,16 +589,14 @@ async function truncateSeries( ? JSON.parse(JSON.stringify(master.recurrenceRules)) : null; const splitKey = splitKeyOf(occurrence, master); - const untilDate = new Date(splitKey); - untilDate.setSeconds(untilDate.getSeconds() - 1); - const until = format(untilDate, "yyyy-MM-dd'T'HH:mm:ss"); + const until = untilBefore(splitKey, master); const truncatedRules = (master.recurrenceRules || []).map(rule => ({ ...rule, until, count: null, })); const patch: Record = { recurrenceRules: truncatedRules }; - const restore: Record = { recurrenceRules: originalRules }; + const restore: Partial = { recurrenceRules: originalRules }; const removed: OverrideMap = {}; const split = wallClockMs(splitKey); for (const [key, override] of Object.entries(master.recurrenceOverrides ?? {})) { @@ -466,11 +604,38 @@ async function truncateSeries( if (at === null || split === null || at < split) continue; removed[key] = override; patch[`recurrenceOverrides/${key}`] = null; - restore[`recurrenceOverrides/${key}`] = override; } + // Put back as the whole map: a pointer into it fails while the series has + // no overrides at all, which the truncation may just have left it with. + if (Object.keys(removed).length > 0) restore.recurrenceOverrides = withoutUpdated(master.recurrenceOverrides!); const announced = scheduling.sendScheduling && userOrganizes(master, scheduling.userEmails ?? []) ? true : undefined; await actions.updateEvent(actions.client, master.id, patch as Partial, announced); - return { restore: restore as Partial, announced, removed }; + return { restore, announced, removed }; +} + +/** + * The UNTIL that ends `series` just before its occurrence at `splitKey`: a + * second before it, on the series' own clock - and earlier by as much as the + * series' zone is behind UTC. 0.16 (0.16.23 checked) takes the UNTIL for + * the series' wall clock, turns it into UTC and compares that with the + * wall clock of each occurrence: in New York "until 09:59:59" kept the + * 10:00 occurrence it was to end before, "until 04:59:59" ends before it. + * 1.0 reads the UNTIL on the series' clock, and ends it there either way as + * long as its occurrences are further apart than that - hours, for a daily + * or weekly series never. The offset is the larger of the split's day and + * the day before, for a change of daylight saving time between them. + */ +function untilBefore(splitKey: string, series: CalendarEvent): string { + let back = 1000; + const zone = !series.showWithoutTime && series.timeZone && isValidTimeZone(series.timeZone) ? series.timeZone : null; + if (zone) { + const instant = new Date(`${moveLocalDateTime(splitKey.slice(0, 19), zone, 'UTC')}Z`); + if (!Number.isNaN(instant.getTime())) { + const offsets = [instant, new Date(instant.getTime() - 86_400_000)].map((at) => getTimeZoneOffsetMs(at, zone)); + back += Math.max(0, -Math.min(...offsets)); + } + } + return shiftLocalDateTime(splitKey, -back); } /** @@ -492,56 +657,86 @@ function remainingRules( /** * The overrides of the new series "this and the following occurrences" - * becomes: those the old series had after the split (truncateSeries), each - * with its occurrence (overrideKeyIn). They keep what the organizer changed - * - an exclusion, a title, a time of their own - but take the new series' - * participants (withParticipantsOf): it asks every attendee again, for each - * occurrence, and an attendee's answer to one occurrence of the old series - * (Stalwart keeps it as an override holding that attendee) would leave the - * others out of it. The occurrence the editor was opened on starts the new - * series, which is what the editor wrote for it: its override stays behind. + * becomes: those the old series had from the split on (truncateSeries), + * each with its occurrence (overrideKeyIn). They keep what the organizer + * changed - an exclusion, a title, a time of their own - but take the new + * series' participants (withParticipantsOf): it asks every attendee again, + * for each occurrence, and an attendee's answer to one occurrence of the old + * series (Stalwart keeps it as an override holding that attendee) would + * leave the others out of it. They take what the user changed in the editor + * as `series`, the old series, took it (withSeriesChanges). The occurrence + * the editor was opened on is the new series' first: moved on its own, it + * stays off the new series' rhythm by as much as before, plus the editor's + * move. */ function followingOverrides( overrides: OverrideMap | undefined, splitKey: string, from: SeriesFrame, - to: SeriesFrame & { participants?: CalendarEvent['participants'] }, + to: SeriesFrame & { participants?: CalendarEvent['participants']; duration?: string }, + series: CalendarEvent, + changes: Partial, + newSeries: Partial, ): OverrideMap | undefined { - if (!overrides || Object.keys(overrides).length === 0) return undefined; + if (!overrides || Object.keys(overrides).length === 0 || !to.recurrenceRules?.length) return undefined; const turned = from.showWithoutTime !== to.showWithoutTime; + const resized = parseDurationSeconds(to.duration) !== parseDurationSeconds(series.duration); const result: OverrideMap = {}; for (const [key, override] of Object.entries(overrides)) { - if (key === splitKey) continue; const target = overrideKeyIn(key, from, to, splitKey); if (!target) continue; + const opened = key === splitKey; const { updated: _updated, ...rest } = override as OverrideEntry; - const entry = withParticipantsOf(rest, to.participants); + let entry = withParticipantsOf(rest, to.participants); if (!entry.excluded) { if (turned) { for (const timing of WHEN_KEYS) delete entry[timing]; } else if (typeof entry.start === 'string') { if (wallClockMs(entry.start) === wallClockMs(key)) { entry.start = target; + } else if (opened) { + entry.start = movedLike(entry.start, splitKey, to.start); } else if (entry.timeZone === undefined && (from.timeZone ?? null) !== (to.timeZone ?? null)) { // A time of its own, on the old series' clock. entry.timeZone = from.timeZone ?? null; } } - if (isEmptyOverride(entry) && seriesOccursAt(to.start, to.recurrenceRules, target)) continue; + if (!turned && resized && typeof entry.duration === 'string' + && (opened || parseDurationSeconds(entry.duration) === parseDurationSeconds(series.duration))) { + entry.duration = to.duration; + } + entry = withSeriesChanges(entry, changes, series, opened); + if (seriesOccursAt(to.start, to.recurrenceRules, target) && changesNothing(entry, target, newSeries)) continue; } result[target] = entry as OverrideMap[string]; } return Object.keys(result).length > 0 ? result : undefined; } +/** What of the old series a new one made from it keeps: its details, where and when, and its reminders. */ +const FOLLOWING_SERIES_KEYS = [ + 'title', 'description', 'descriptionContentType', 'locations', 'virtualLocations', 'links', 'keywords', + 'categories', 'color', 'locale', 'status', 'freeBusyStatus', 'privacy', 'calendarIds', 'useDefaultAlerts', + 'duration', 'timeZone', 'showWithoutTime', +] as const; + /** * The new series "this and the following occurrences" becomes: the old - * series' details with the editor's changes, from `occurrence` on, with - * what is left of its COUNT and the overrides it takes over (`overrides`, - * from truncateSeries). It is a new event with a UID of its own, so every - * attendee is asked (participantsAskedAgain): Stalwart invites to a new - * event only attendees who have not answered, and with the answers the old - * series had, those who had answered never got the new one. + * series with what the user changed in the editor, from `occurrence` on, + * with what is left of its COUNT and the overrides it takes over + * (`overrides`, from truncateSeries). It is a new event with a UID of its + * own, so every attendee is asked (participantsAskedAgain): Stalwart invites + * to a new event only attendees who have not answered, and with the answers + * the old series had, those who had answered never got the new one. + * + * It goes on where the old series would have: from the split occurrence's + * recurrence id, on the old series' clock and in its zone, moved only as far + * as the editor moved the occurrence (seriesWhen). It started at the + * occurrence's own time, in the user's zone: a Monday meeting in New York + * whose one occurrence had moved to Tuesday became a Tuesday series in + * Berlin from there on. What the editor showed of that occurrence alone - + * its own time, a title of its own - stays with it (followingOverrides). + * Without the editor's state the updates are the new series as they are. */ export function followingSeriesData( occurrence: CalendarEvent, @@ -550,30 +745,39 @@ export function followingSeriesData( originalRules: CalendarEvent['recurrenceRules'], options: { editor?: EventEditorState; overrides?: OverrideMap } = {}, ): Partial { + const { editor } = options; const splitKey = splitKeyOf(occurrence, master); // A rule the user changed in the editor is the new series' own, its COUNT // included; one the user left alone continues the old series. - const ruleChanged = options.editor - ? !!options.editor.ruleChanged + const ruleChanged = editor + ? !!editor.ruleChanged : updates.recurrenceRules !== undefined && !sameRecurrenceRules(updates.recurrenceRules, originalRules); - const { recurrenceRules: editorRules, ...changes } = updates; - const data: Partial = { - title: master.title, - description: master.description, - duration: master.duration, - timeZone: master.timeZone, - calendarIds: { ...master.calendarIds }, - status: master.status, - freeBusyStatus: master.freeBusyStatus, - privacy: master.privacy, - showWithoutTime: master.showWithoutTime, - ...changes, - recurrenceRules: ruleChanged && editorRules !== undefined ? editorRules : remainingRules(originalRules, master.start, splitKey), - start: updates.start || occurrence.recurrenceId || occurrence.start, - }; + const changes = editor ? changedEditorFields(occurrence, updates, editor) : updates; + const { recurrenceRules: editorRules, ...changed } = changes; + + const data: Partial = {}; + for (const key of FOLLOWING_SERIES_KEYS) { + if (master[key] !== undefined) (data as Record)[key] = master[key]; + } + data.calendarIds = { ...master.calendarIds }; + // While the calendar's default reminders are on, 1.0 lists them as the + // series' own: they come with the switch, not as copies. + if (!master.useDefaultAlerts && master.alerts) data.alerts = master.alerts; + Object.assign(data, changed); + data.recurrenceRules = ruleChanged && editorRules !== undefined ? editorRules : remainingRules(originalRules, master.start, splitKey); + if (editor) { + Object.assign(data, seriesWhen(occurrence, { ...master, start: splitKey }, updates, editor.when)); + data.start ??= splitKey; + for (const key of ['participants', 'organizerCalendarAddress'] as const) { + if (updates[key] !== undefined) (data as Record)[key] = updates[key]; + } + } else { + data.start = updates.start || occurrence.recurrenceId || occurrence.start; + } delete data.id; delete data.uid; delete data.recurrenceId; + delete data.recurrenceOverrides; if (data.participants) { data.participants = participantsAskedAgain({ participants: data.participants, @@ -585,13 +789,45 @@ export function followingSeriesData( { start: master.start, timeZone: master.timeZone, showWithoutTime: !!master.showWithoutTime, recurrenceRules: originalRules }, { start: data.start!, timeZone: data.timeZone, showWithoutTime: !!data.showWithoutTime, - recurrenceRules: data.recurrenceRules ?? null, participants: data.participants, + recurrenceRules: data.recurrenceRules ?? null, participants: data.participants, duration: data.duration, }, + master, editor ? changed : {}, data, ); if (overrides) data.recurrenceOverrides = overrides; return data; } +/** + * Change `occurrence` of `master`, which has no override yet, so that the + * change reaches every attendee who has not declined - in the two steps the + * calendar store takes for an occurrence of an expanded series (its + * updateNewOverrideForEveryone). Stalwart sends a new override only to + * attendees who have not answered, a change to an existing one to all who + * have not declined: the occurrence first gets its override as it is, + * without messages, and the change then goes out as a change of it. This + * is the series itself standing for its first occurrence - one opened from + * a link or a search - which changed in one write that reached nobody who + * had answered. The override is written on the series, under the key it + * keeps, so the second step needs no fresh read. + */ +async function overrideForEveryone( + occurrence: CalendarEvent, + master: CalendarEvent, + updates: Partial, + actions: Pick, +): Promise { + const key = resolveOverrideKey(occurrence, occurrence.recurrenceOverrides); + const copy = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, {}, { seriesKeys: true })); + if (!key || !copy) return false; + await actions.updateEvent(actions.client, master.id, copy, false); + const written = (copy.recurrenceOverrides?.[key] ?? (copy as Record)[`recurrenceOverrides/${key}`]) as OverrideMap[string]; + const overridden = { ...occurrence, recurrenceOverrides: { ...(occurrence.recurrenceOverrides ?? {}), [key]: written } }; + const change = buildFallbackOverridePatch(overridden, withNewOverrideDetails(overridden, updates, { seriesKeys: true })); + if (!change) return false; + await actions.updateEvent(actions.client, master.id, change, true); + return true; +} + /** * Save the editor's change to one occurrence for `scope`. Resolves to false * when the series the occurrence belongs to cannot be found. @@ -621,6 +857,11 @@ export async function saveOccurrenceEdit( const master = await findMasterEvent(event); if (master) { const occurrence = asOccurrenceOf(event, master); + const participants = updates.participants === undefined ? occurrence.participants : updates.participants; + if (sendScheduling && createsOverride(occurrence) && userOrganizes({ ...event, participants }, edit.userEmails) + && hasAnsweredAttendees({ ...occurrence, participants })) { + return overrideForEveryone(occurrence, master, updates, actions); + } const overridePatch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true })); if (!overridePatch) return false; await updateEvent(client, master.id, overridePatch, sendScheduling); @@ -644,7 +885,11 @@ export async function saveOccurrenceEdit( overrides: removed, }); try { - await createEvent(client, newEventData, sendScheduling); + // The calendar store answers most failures with null, and reports + // them itself: the series was left ended, and the rest gone for + // everyone, while the save said it had worked. + const created = await createEvent(client, newEventData, sendScheduling); + if (!created) throw new Error('Failed to create the rest of the series'); } catch (createError) { debug.error('Failed to create new series, rolling back master truncation:', createError); try {