mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
fix: keep series intact when a split fails and when edited from one occurrence
Edits of a recurring meeting from one of its occurrences went wrong in several ways (live on 1.0 and 0.16.23, attendees included): - A "this and following" split whose new series could not be created stayed half done: the calendar store answers most failures with null, and only a thrown error put the old series back. The series stayed ended, told so to the attendees, and the save said it had worked. A null now puts the series back as well and fails the save. The rollback writes the old overrides back as a whole map: a pointer into a series without overrides fails, and the split may just have removed its only one. - "This occurrence" on the series itself (a meeting opened from a link or a search result) went out in one write that reached no attendee who had answered. It now takes the same two steps as an occurrence of an expanded series: the override as it is without messages, then the change of it. - The new series of a split started at the opened occurrence's own time and in the user's zone: Mondays in New York, one occurrence moved to Tuesday, renamed from there by a user in Berlin, became a Tuesday series in Berlin. It now goes on from that occurrence's recurrence id, in the series' zone, moved only by what the editor moved; the occurrence keeps its own time through its override. It takes the old series' details with what the user changed, not the opened occurrence's own ones. - "All events" from an occurrence with an override of its own wrote that occurrence's own title, copied into its override, back to the series: a new room there renamed the series. Only the fields the user changed go to the series now; the opened occurrence's override takes them too (on 1.0 also where it holds them as pointers), and other overrides where they only held the series' old value. - The move of an "all" edit was measured on the user's clock: across a week in which only one of the two zones changes to or from summer time, the series moved an hour more or less than picked. It is now measured on the series' own clock. - 0.16 (0.16.23 checked) reads a rule's UNTIL as the series' wall clock taken for UTC, so in a zone behind UTC a split or "delete this and following" kept the occurrence it was to end before. The UNTIL is now earlier by the zone's offset, which ends the series there on 0.16 and 1.0 alike. - An applied counter proposal for one occurrence of a recurring meeting went to the first event with its uid. It now goes to the occurrence the proposal names; one for a whole series of which only occurrences are in view is not applied to any of them.
This commit is contained in:
@@ -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<string, unknown>) => ({
|
||||
uid: 'uid-7', title: 'Weekly', start: '2026-03-27T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', participants, ...extra,
|
||||
});
|
||||
const openCounter = async (id: string) => {
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id, attachments: [{ partId: '1', blobId: `blob-${id}`, size: 256, type: 'text/calendar; method=COUNTER', name: 'counter.ics' }] })} />);
|
||||
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) => ({
|
||||
|
||||
@@ -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<CalendarEvent>, b: Partial<CalendarEvent>): 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>): 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<CalendarEvent> | null,
|
||||
proposedEvent: Partial<CalendarEvent> | 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);
|
||||
|
||||
@@ -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<CalendarEvent> = {}) => ({
|
||||
...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<string, unknown>;
|
||||
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<string, Record<string, unknown>>;
|
||||
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<CalendarEvent>, 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<typeof vi.fn> };
|
||||
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<CalendarEvent>, _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 () => {
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
|
||||
+304
-59
@@ -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<string>([...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<string, { name?: string | null; uri?: string | null }> | 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<CalendarEvent>,
|
||||
editor?: Pick<EventEditorState, 'ruleChanged'>,
|
||||
): Partial<CalendarEvent> {
|
||||
const ruleChanged = editor ? !!editor.ruleChanged : !sameRecurrenceRules(occurrence.recurrenceRules, updates.recurrenceRules);
|
||||
const changed: Record<string, unknown> = {};
|
||||
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<string, unknown>)[key]);
|
||||
}
|
||||
if (differs) changed[key] = value;
|
||||
}
|
||||
return changed as Partial<CalendarEvent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `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<CalendarEvent>, 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<string, unknown>)[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<CalendarEvent>): 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<string, unknown>)[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<CalendarEvent>,
|
||||
options: { ruleChanged: boolean; askAgain: boolean },
|
||||
options: { ruleChanged: boolean; askAgain: boolean; changes: Partial<CalendarEvent> },
|
||||
): 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<ParticipantSaveContext, 'rescheduled'>,
|
||||
): Partial<CalendarEvent> {
|
||||
const series: Partial<CalendarEvent> = { ...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<CalendarEvent> = { ...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<string, unknown> = { recurrenceRules: truncatedRules };
|
||||
const restore: Record<string, unknown> = { recurrenceRules: originalRules };
|
||||
const restore: Partial<CalendarEvent> = { 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<CalendarEvent>, announced);
|
||||
return { restore: restore as Partial<CalendarEvent>, 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<CalendarEvent>,
|
||||
newSeries: Partial<CalendarEvent>,
|
||||
): 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<CalendarEvent> {
|
||||
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<CalendarEvent> = {
|
||||
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<CalendarEvent> = {};
|
||||
for (const key of FOLLOWING_SERIES_KEYS) {
|
||||
if (master[key] !== undefined) (data as Record<string, unknown>)[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<string, unknown>)[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<CalendarEvent>,
|
||||
actions: Pick<OccurrenceEditActions, 'client' | 'updateEvent'>,
|
||||
): Promise<boolean> {
|
||||
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<string, unknown>)[`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 {
|
||||
|
||||
Reference in New Issue
Block a user