mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: move a series' changed and cancelled occurrences along when it moves or splits
A series keeps its changed and cancelled occurrences as overrides keyed by recurrence id, where the rule puts each occurrence. The app's series edits left them behind and made more of a series than was asked (0.16 and 1.0 alike, attendees included): - "All events" that moved the series kept the overrides under the old ids: a cancelled occurrence came back, a retitled one showed twice. It also wrote everything the editor showed to the series, so a new room on an occurrence with a title of its own renamed the series, and it measured a move on the calendar's clock, so across a week in which only one of two zones changes to or from summer time the series moved an hour more or less than picked. - "This and following events" ended the rule but not the overrides after the split, which showed on next to the new series; the new series got the whole COUNT again and started at the opened occurrence's own time in the calendar's zone. Deleting "this and following" left the later overrides too. - A failed create of the rest of the series put back only the rule, and without telling the attendees the truncation had told. Now, as on the web (`lib/recurrence-scope-edit.ts`, a port of the webmail module of the same name): - "All" moves each override with its occurrence in the same update: by the series' move, onto the same day at the new time when the series turns all-day or timed. An occurrence moved on its own keeps its time, except the one the editor was opened on; a new rule drops the overrides it no longer has, and overrides that move ask their attendees again when the series does. Only the fields the user changed go to the series, and the move is measured on the series' own clock. - The split removes the overrides from the split on together with the rule and gives them to the new series, re-keyed and with its participants (0.16 keeps an override as an event of its own). The new series goes on from the split's recurrence id in the series' zone, moved only by the editor's move; its COUNT is what is left, an UNTIL stays, and a rule the user changed is taken as it is. It takes the old series' details, not the opened occurrence's own ones. - A failed create puts back the rule and the whole overrides map (a pointer fails on a series left without overrides), with the truncation's scheduling, and fails the save; a store that answers with nothing counts as failed. - The truncation tells the attendees only when the user organizes the series and sends invitations; an attendee ends their own copy quietly. The scope handling moved from CalendarScreen into the new module, where it is tested. The editor hands its dates to the save. An invitation to one occurrence alone, which has no series, is changed or deleted as itself for every scope instead of making a second event.
This commit is contained in:
@@ -66,7 +66,7 @@ import {
|
||||
userOrganizes,
|
||||
type Attendee,
|
||||
} from '../../lib/calendar-participants';
|
||||
import type { SeriesEdit } from '../../lib/recurrence-overrides';
|
||||
import type { SeriesEdit } from '../../lib/recurrence-scope-edit';
|
||||
import { Button } from '..';
|
||||
import { ParticipantInput } from './ParticipantInput';
|
||||
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
||||
@@ -373,7 +373,9 @@ export function EventModal({
|
||||
// Only the organizer sends: on an invitation someone else sent, a
|
||||
// save would reach the organizer as a reply.
|
||||
sendSchedulingMessages: organizes && attendees.length > 0 && sendInvitations,
|
||||
edit: { attendees, timesMoved, ruleChanged },
|
||||
// The dates as shown: a series moves by the occurrence's move, on
|
||||
// its own clock (lib/recurrence-scope-edit).
|
||||
edit: { attendees, when: { allDay, start, end }, ruleChanged },
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { CalendarEvent } from '../../api/types';
|
||||
import {
|
||||
askAttendeesAgain,
|
||||
buildParticipantMap,
|
||||
collectUserCalendarAddresses,
|
||||
getParticipantCount,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
participantFieldsToSave,
|
||||
participantLimitMessage,
|
||||
participantLimitOf,
|
||||
participantsAskedAgain,
|
||||
seedAttendees,
|
||||
seriesParticipantFields,
|
||||
userOrganizes,
|
||||
@@ -349,16 +349,32 @@ describe('answers on save (Stalwart 0.16 and 1.0)', () => {
|
||||
|
||||
it('tells whether anyone but the organizer answered', () => {
|
||||
expect(hasAnsweredAttendees(meeting.participants)).toBe(true);
|
||||
expect(hasAnsweredAttendees(askAttendeesAgain(meeting.participants!))).toBe(false);
|
||||
expect(hasAnsweredAttendees(participantsAskedAgain(meeting))).toBe(false);
|
||||
expect(hasAnsweredAttendees(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('asks everyone but the organizer again for a new series', () => {
|
||||
const asked = askAttendeesAgain(meeting.participants!, ['me@example.com']);
|
||||
const asked = participantsAskedAgain(meeting)!;
|
||||
|
||||
expect(asked.p0.participationStatus).toBe('accepted');
|
||||
expect([asked.p1, asked.p2, asked.p3].map((p) => p.participationStatus)).toEqual(['needs-action', 'needs-action', 'needs-action']);
|
||||
});
|
||||
|
||||
it('knows the organizer by its address when the server gives it no role, and asks nobody when nobody answered', () => {
|
||||
const roleless = {
|
||||
organizerCalendarAddress: 'mailto:me@example.com',
|
||||
participants: {
|
||||
p0: { calendarAddress: 'mailto:me@example.com', participationStatus: 'accepted' },
|
||||
p1: { calendarAddress: 'mailto:bob@example.com', participationStatus: 'accepted' },
|
||||
},
|
||||
} as Partial<CalendarEvent>;
|
||||
|
||||
expect(participantsAskedAgain(roleless)).toEqual({
|
||||
p0: roleless.participants!.p0,
|
||||
p1: { ...roleless.participants!.p1, participationStatus: 'needs-action' },
|
||||
});
|
||||
expect(participantsAskedAgain({ participants: { p3: meeting.participants!.p3 } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('seriesParticipantFields (all occurrences)', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { expandRecurringEvents } from '../recurrence-expansion';
|
||||
import { expandRecurringEvents, occurrencesBefore, seriesOccursAt } from '../recurrence-expansion';
|
||||
import { computeFireTime } from '../calendar-alert-scheduler';
|
||||
import type { CalendarEvent, RecurrenceRule } from '../../api/types';
|
||||
|
||||
@@ -492,3 +492,44 @@ describe('expandRecurringEvents - the master\'s instants', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// What the scope edits ask when a series is split or changed: how much of
|
||||
// a COUNT the occurrences before a date use up, and whether a series still
|
||||
// has an occurrence at a recurrence id.
|
||||
describe('occurrencesBefore', () => {
|
||||
const rule = (partial: Partial<RecurrenceRule>): RecurrenceRule => ({ '@type': 'RecurrenceRule', frequency: 'weekly', ...partial });
|
||||
|
||||
it('counts the occurrences before a date, the start included', () => {
|
||||
expect(occurrencesBefore('2026-12-07T10:00:00', rule({ frequency: 'weekly', count: 6 }), '2026-12-21T10:00:00')).toBe(2);
|
||||
expect(occurrencesBefore('2026-12-07T10:00:00', rule({ frequency: 'weekly', count: 6 }), '2026-12-07T10:00:00')).toBe(0);
|
||||
});
|
||||
|
||||
it('stops where the rule ends', () => {
|
||||
expect(occurrencesBefore('2026-12-07T10:00:00', rule({ frequency: 'weekly', count: 2 }), '2027-03-01T10:00:00')).toBe(2);
|
||||
expect(occurrencesBefore('2026-12-07T10:00:00', rule({ frequency: 'daily', until: '2026-12-09T23:59:59' }), '2027-03-01T10:00:00')).toBe(3);
|
||||
});
|
||||
|
||||
it('counts past the cap on occurrences the expansion shows', () => {
|
||||
expect(occurrencesBefore('2026-01-01T08:00:00', rule({ frequency: 'daily', count: 900 }), '2027-12-31T08:00:00')).toBe(729);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seriesOccursAt', () => {
|
||||
const weekly: RecurrenceRule[] = [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 }];
|
||||
|
||||
it('is true for the start and each occurrence the rule gives', () => {
|
||||
expect(seriesOccursAt('2026-12-09T10:00:00', weekly, '2026-12-09T10:00:00')).toBe(true);
|
||||
expect(seriesOccursAt('2026-12-09T10:00:00', weekly, '2027-01-06T10:00:00')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for another time, another day, or after the count', () => {
|
||||
expect(seriesOccursAt('2026-12-09T10:00:00', weekly, '2026-12-16T11:00:00')).toBe(false);
|
||||
expect(seriesOccursAt('2026-12-09T10:00:00', weekly, '2026-12-17T10:00:00')).toBe(false);
|
||||
expect(seriesOccursAt('2026-12-09T10:00:00', weekly, '2027-01-13T10:00:00')).toBe(false);
|
||||
expect(seriesOccursAt('2026-12-09T10:00:00', null, '2026-12-16T10:00:00')).toBe(false);
|
||||
});
|
||||
|
||||
it('knows all-day occurrences by their day', () => {
|
||||
expect(seriesOccursAt('2026-12-09T00:00:00', [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], '2026-12-23T00:00:00')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { CalendarEvent } from '../../api/types';
|
||||
import {
|
||||
buildAllScopeEdit,
|
||||
buildAllScopeUpdates,
|
||||
buildFutureSeriesData,
|
||||
buildFutureSeriesEdit,
|
||||
isRecurringSeriesMember,
|
||||
truncateRecurrenceRules,
|
||||
} from '../recurrence-overrides';
|
||||
import { seedAttendees } from '../calendar-participants';
|
||||
import { isRecurringSeriesMember } from '../recurrence-overrides';
|
||||
|
||||
// The scope edits ("this", "this and following", "all") are tested in
|
||||
// recurrence-scope-edit.test.ts.
|
||||
|
||||
const master: CalendarEvent = {
|
||||
id: 'ev-1',
|
||||
@@ -23,10 +18,10 @@ const master: CalendarEvent = {
|
||||
|
||||
const occurrence: CalendarEvent = {
|
||||
...master,
|
||||
id: 'ev-1:2026-03-04T08:00:00.000Z',
|
||||
id: 'ev-1:2026-03-04T09:00:00',
|
||||
originalId: 'ev-1',
|
||||
start: '2026-03-04T09:00:00',
|
||||
recurrenceId: '2026-03-04T08:00:00.000Z',
|
||||
recurrenceId: '2026-03-04T09:00:00',
|
||||
};
|
||||
|
||||
describe('isRecurringSeriesMember', () => {
|
||||
@@ -37,174 +32,3 @@ describe('isRecurringSeriesMember', () => {
|
||||
expect(isRecurringSeriesMember({ originalId: 'raw' } as CalendarEvent)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateRecurrenceRules', () => {
|
||||
it('ends the rule one second before the occurrence slot and drops count', () => {
|
||||
const rules = truncateRecurrenceRules(master.recurrenceRules, occurrence);
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].count).toBeUndefined();
|
||||
// recurrenceId is the UTC instant; until is local wall-clock, one second earlier.
|
||||
const expected = new Date('2026-03-04T08:00:00.000Z');
|
||||
expected.setSeconds(expected.getSeconds() - 1);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const local =
|
||||
`${expected.getFullYear()}-${pad(expected.getMonth() + 1)}-${pad(expected.getDate())}` +
|
||||
`T${pad(expected.getHours())}:${pad(expected.getMinutes())}:${pad(expected.getSeconds())}`;
|
||||
expect(rules[0].until).toBe(local);
|
||||
expect(rules[0].frequency).toBe('daily');
|
||||
});
|
||||
|
||||
it('treats an all-day recurrenceId as a local date', () => {
|
||||
const rules = truncateRecurrenceRules(
|
||||
[{ frequency: 'weekly' }],
|
||||
{ recurrenceId: '2026-03-04', start: '2026-03-04T00:00:00' },
|
||||
);
|
||||
expect(rules[0].until).toBe('2026-03-03T23:59:59');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAllScopeUpdates', () => {
|
||||
it('drops an unchanged occurrence start so the master is not moved', () => {
|
||||
const out = buildAllScopeUpdates(
|
||||
{ title: 'Renamed', start: '2026-03-04T09:00:00', recurrenceId: 'x' },
|
||||
occurrence,
|
||||
master,
|
||||
);
|
||||
expect(out).toEqual({ title: 'Renamed' });
|
||||
});
|
||||
|
||||
it('applies the delta of a moved occurrence to the master start', () => {
|
||||
const out = buildAllScopeUpdates(
|
||||
{ start: '2026-03-04T10:30:00' },
|
||||
occurrence,
|
||||
master,
|
||||
);
|
||||
expect(out.start).toBe('2026-03-02T10:30:00');
|
||||
});
|
||||
|
||||
it('keeps start when editing the master itself', () => {
|
||||
const out = buildAllScopeUpdates({ start: '2026-03-05T09:00:00' }, master, master);
|
||||
expect(out.start).toBe('2026-03-05T09:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFutureSeriesData', () => {
|
||||
it('clones the master with the original rules, edits on top and the occurrence start', () => {
|
||||
const data = buildFutureSeriesData(
|
||||
master,
|
||||
master.recurrenceRules ?? null,
|
||||
occurrence,
|
||||
{ title: 'New title' },
|
||||
);
|
||||
expect(data.title).toBe('New title');
|
||||
expect(data.start).toBe('2026-03-04T09:00:00');
|
||||
expect(data.recurrenceRules).toEqual(master.recurrenceRules);
|
||||
expect(data.duration).toBe('PT30M');
|
||||
expect(data.timeZone).toBe('Europe/Berlin');
|
||||
expect('id' in data).toBe(false);
|
||||
expect('uid' in data).toBe(false);
|
||||
expect('recurrenceId' in data).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers a start the user moved', () => {
|
||||
const data = buildFutureSeriesData(master, null, occurrence, { start: '2026-03-04T11:00:00' });
|
||||
expect(data.start).toBe('2026-03-04T11:00:00');
|
||||
expect('recurrenceRules' in data).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('series edits from an occurrence the user organizes', () => {
|
||||
const participants = {
|
||||
p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
p1: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||
} as CalendarEvent['participants'];
|
||||
const series: CalendarEvent = { ...master, organizerCalendarAddress: 'mailto:me@example.com', participants };
|
||||
// The fourth occurrence, stretched to an hour, which Bob declined.
|
||||
const fourth: CalendarEvent = {
|
||||
...series,
|
||||
id: 'ev-1:2026-03-05T09:00:00',
|
||||
start: '2026-03-05T09:00:00',
|
||||
duration: 'PT1H',
|
||||
recurrenceId: '2026-03-05T09:00:00',
|
||||
participants: { ...participants, p1: { ...participants!.p1, participationStatus: 'declined' } },
|
||||
};
|
||||
const me = { userEmail: 'me@example.com', userEmails: ['me@example.com'] };
|
||||
// What the editor saves for an unchanged form: the occurrence's own times,
|
||||
// the rule written back and the occurrence's participants.
|
||||
const formSave = {
|
||||
title: 'Standup (moved room)',
|
||||
start: fourth.start,
|
||||
duration: fourth.duration,
|
||||
timeZone: fourth.timeZone,
|
||||
showWithoutTime: false,
|
||||
recurrenceRules: [{ frequency: 'daily', interval: 1, count: 10 }],
|
||||
participants: fourth.participants,
|
||||
} as Partial<CalendarEvent>;
|
||||
const attendees = seedAttendees(fourth, ['me@example.com']);
|
||||
|
||||
it('changes only what the user changed when nothing moved (all occurrences)', () => {
|
||||
const update = buildAllScopeEdit(formSave, fourth, series, { attendees, timesMoved: false, ruleChanged: false }, me);
|
||||
|
||||
// Not the fourth's length, rule rewrite or participants (Bob's decline).
|
||||
expect(update).toEqual({ title: 'Standup (moved room)' });
|
||||
});
|
||||
|
||||
it('moves the series by the occurrence\'s delta and asks its attendees again (all occurrences)', () => {
|
||||
const moved = { ...formSave, start: '2026-03-05T10:00:00' };
|
||||
|
||||
const update = buildAllScopeEdit(moved, fourth, series, { attendees, timesMoved: true, ruleChanged: false }, me);
|
||||
|
||||
expect(update.start).toBe('2026-03-02T10:00:00');
|
||||
const statuses = Object.values(update.participants ?? {}).map((p) => [p.calendarAddress, p.participationStatus]);
|
||||
expect(statuses).toEqual([['mailto:me@example.com', 'accepted'], ['mailto:bob@example.com', 'needs-action']]);
|
||||
});
|
||||
|
||||
it('asks every attendee of the new series again (this and following)', () => {
|
||||
const data = buildFutureSeriesEdit(series, series.recurrenceRules!, fourth, formSave, ['me@example.com']);
|
||||
|
||||
const statuses = Object.values(data.participants ?? {}).map((p) => p.participationStatus);
|
||||
expect(statuses).toEqual(['accepted', 'needs-action']);
|
||||
});
|
||||
|
||||
it('leaves the answers of a series someone else organizes alone (this and following)', () => {
|
||||
const invitation = { ...series, organizerCalendarAddress: 'mailto:boss@example.com', participants: {
|
||||
...participants, p0: { ...participants!.p0, calendarAddress: 'mailto:boss@example.com' },
|
||||
} } as CalendarEvent;
|
||||
|
||||
const data = buildFutureSeriesEdit(invitation, invitation.recurrenceRules!, fourth, {}, ['me@example.com']);
|
||||
|
||||
expect(Object.values(data.participants ?? {}).map((p) => p.participationStatus)).toEqual(['accepted', 'accepted']);
|
||||
});
|
||||
|
||||
// The series in the owner's calendar, shared with the user read & write:
|
||||
// Stalwart sends the user's change from the owner (isOrigin, 0.16 and 1.0).
|
||||
it('asks the attendees again when the user changes the owner\'s series in a shared calendar', () => {
|
||||
const owners = {
|
||||
...series,
|
||||
isOrigin: true,
|
||||
organizerCalendarAddress: 'mailto:owner@example.com',
|
||||
participants: { ...participants, p0: { ...participants!.p0, calendarAddress: 'mailto:owner@example.com' } },
|
||||
} as CalendarEvent;
|
||||
const occurrenceOfOwners = { ...fourth, isOrigin: true, organizerCalendarAddress: owners.organizerCalendarAddress, participants: {
|
||||
...owners.participants, p1: { ...owners.participants!.p1, participationStatus: 'declined' },
|
||||
} } as CalendarEvent;
|
||||
const sharee = { userEmail: 'sharee@example.com', userEmails: ['sharee@example.com'] };
|
||||
const rows = seedAttendees(occurrenceOfOwners, ['sharee@example.com']);
|
||||
|
||||
const following = buildFutureSeriesEdit(owners, owners.recurrenceRules!, occurrenceOfOwners, {
|
||||
participants: owners.participants,
|
||||
}, ['sharee@example.com']);
|
||||
const moved = buildAllScopeEdit({ ...formSave, start: '2026-03-05T10:00:00' }, occurrenceOfOwners, owners, {
|
||||
attendees: rows, timesMoved: true, ruleChanged: false,
|
||||
}, sharee);
|
||||
|
||||
expect(Object.values(following.participants ?? {}).map((p) => p.participationStatus)).toEqual(['accepted', 'needs-action']);
|
||||
expect(Object.values(moved.participants ?? {}).map((p) => [p.calendarAddress, p.participationStatus]))
|
||||
.toEqual([['mailto:owner@example.com', 'accepted'], ['mailto:bob@example.com', 'needs-action']]);
|
||||
});
|
||||
|
||||
it('keeps writing the times when the flags are not given', () => {
|
||||
expect(buildAllScopeUpdates({ start: '2026-03-05T09:00:00', duration: 'PT1H' }, fourth, series))
|
||||
.toEqual({ duration: 'PT1H' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,978 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// The device and the calendar are in Berlin unless a test says otherwise.
|
||||
vi.hoisted(() => {
|
||||
process.env.TZ = 'Europe/Berlin';
|
||||
});
|
||||
|
||||
import { addDays, addHours, addMinutes, startOfDay } from 'date-fns';
|
||||
import {
|
||||
deleteOccurrence,
|
||||
followingSeriesData,
|
||||
saveOccurrenceEdit,
|
||||
seriesUpdatesFromOccurrence,
|
||||
type OccurrenceEditActions,
|
||||
type SeriesEdit,
|
||||
} from '../recurrence-scope-edit';
|
||||
import { expandRecurringEvents } from '../recurrence-expansion';
|
||||
import { getEventStartDate } from '../calendar-utils';
|
||||
import { editorTimesFromEvent } from '../event-editor-times';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import type { CalendarEvent, Participant } from '../../api/types';
|
||||
|
||||
// "All occurrences" from the editor opened on one occurrence: the editor's
|
||||
// dates and participants are that occurrence's. Written to the series as
|
||||
// they were, a rename from the third occurrence moved the series' start to
|
||||
// it (the first two occurrences gone) and gave every occurrence the third
|
||||
// one's answers - here userb's "declined" for that day only.
|
||||
|
||||
type Version = '0.16' | '1.0';
|
||||
const ORGANIZER = 'userc@example.org';
|
||||
const ATTENDEE = 'userb@example.org';
|
||||
// Someone userc shares the calendar with, read & write; not invited.
|
||||
const SHAREE = 'usera@example.org';
|
||||
|
||||
// Participants as each version stores them: 0.16.23 drops an attendee's
|
||||
// roles, 1.0 keeps them.
|
||||
function participant(version: Version, address: string, owner: boolean, status: string): Participant {
|
||||
return {
|
||||
'@type': 'Participant', calendarAddress: `mailto:${address}`, email: address,
|
||||
...(owner ? { roles: { owner: true } } : version === '1.0' ? { roles: { attendee: true } } : {}),
|
||||
participationStatus: status, scheduleAgent: 'server', expectReply: !owner, kind: 'individual',
|
||||
} as unknown as Participant;
|
||||
}
|
||||
|
||||
const statusOf = (participants: CalendarEvent['participants'] | undefined, address: string) =>
|
||||
Object.values(participants ?? {}).find((p) => p.calendarAddress === `mailto:${address}`)?.participationStatus;
|
||||
|
||||
// Weekly on Mondays from 5 October, 10:00 in Berlin, five times. userb
|
||||
// accepted the series and declined 19 October alone: 1.0 returns that
|
||||
// override as a PatchObject, 0.16 as the whole participants map.
|
||||
function series(version: Version): CalendarEvent {
|
||||
const participants = {
|
||||
org: participant(version, ORGANIZER, true, 'accepted'),
|
||||
b: participant(version, ATTENDEE, false, 'accepted'),
|
||||
};
|
||||
return {
|
||||
id: 'b', uid: 'weekly@example.org', '@type': 'Event', title: 'Planning',
|
||||
start: '2026-10-05T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false,
|
||||
utcStart: '2026-10-05T08:00:00Z', utcEnd: '2026-10-05T09:00:00Z',
|
||||
calendarIds: { cal: true }, organizerCalendarAddress: `mailto:${ORGANIZER}`, participants, isOrigin: true,
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 }],
|
||||
recurrenceOverrides: version === '1.0'
|
||||
? { '2026-10-19T10:00:00': { 'participants/b/participationStatus': 'declined' } }
|
||||
: { '2026-10-19T10:00:00': { participants: { ...participants, b: participant(version, ATTENDEE, false, 'declined') } } },
|
||||
excludedRecurrenceRules: null, alerts: null,
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
// 19 October as the server expands it (0.16.20 on, and 1.0): a synthetic id
|
||||
// naming its base event, hydrated from it.
|
||||
function serverOccurrence(version: Version): CalendarEvent {
|
||||
const base = series(version);
|
||||
return {
|
||||
...base,
|
||||
id: 'maaaaac', baseEventId: 'b',
|
||||
start: '2026-10-19T10:00:00', utcStart: '2026-10-19T08:00:00Z', utcEnd: '2026-10-19T09:00:00Z',
|
||||
recurrenceId: '2026-10-19T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin',
|
||||
participants: { ...base.participants, b: participant(version, ATTENDEE, false, 'declined') },
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
// 19 October as the device expands it (0.16 before 0.16.20).
|
||||
function deviceOccurrence(): CalendarEvent {
|
||||
const occurrences = expandRecurringEvents([series('0.16')], '2026-10-01T00:00:00', '2026-11-30T00:00:00');
|
||||
return occurrences.find((o) => o.recurrenceId === '2026-10-19T10:00:00')!;
|
||||
}
|
||||
|
||||
const occurrences: Array<[string, Version, () => CalendarEvent]> = [
|
||||
['1.0', '1.0', () => serverOccurrence('1.0')],
|
||||
['0.16.20+', '0.16', () => serverOccurrence('0.16')],
|
||||
['0.16 before 0.16.20', '0.16', deviceOccurrence],
|
||||
];
|
||||
|
||||
/** What the editor had, opened on `occurrence` and saved as it came unless changed. */
|
||||
function editorOn(occurrence: CalendarEvent, change: Partial<SeriesEdit> = {}): SeriesEdit {
|
||||
return {
|
||||
attendees: [{ name: '', email: ATTENDEE }],
|
||||
when: editorTimesFromEvent(occurrence),
|
||||
...change,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the editor writes for `occurrence` (EventModal handleSave) with `change`. */
|
||||
function editorUpdates(occurrence: CalendarEvent, change: Partial<CalendarEvent> = {}): Partial<CalendarEvent> {
|
||||
return {
|
||||
title: occurrence.title, start: occurrence.start, duration: occurrence.duration,
|
||||
timeZone: occurrence.timeZone, showWithoutTime: false, recurrenceRules: occurrence.recurrenceRules,
|
||||
// The occurrence's participants, rebuilt with its answers.
|
||||
participants: occurrence.participants,
|
||||
...change,
|
||||
};
|
||||
}
|
||||
|
||||
const context = { userEmail: ORGANIZER, userEmails: [ORGANIZER] };
|
||||
|
||||
/** The calendar store's actions as the scope code uses them. */
|
||||
function actions(master: CalendarEvent | null) {
|
||||
return {
|
||||
updateEvent: vi.fn(async (_id: string, _changes: Partial<CalendarEvent> | Record<string, unknown>, _options?: { sendSchedulingMessages?: boolean }) => undefined),
|
||||
// The created event, or nothing when a store answers a failure so.
|
||||
createEvent: vi.fn(async (event: Partial<CalendarEvent>, _calendarId: string, _options?: { sendSchedulingMessages?: boolean }) =>
|
||||
({ ...event, id: 'n1' }) as CalendarEvent | null),
|
||||
getMasterEvent: vi.fn(async (_occurrence: CalendarEvent) => master),
|
||||
deleteEvent: vi.fn(async (_id: string) => undefined),
|
||||
} satisfies OccurrenceEditActions & { deleteEvent: (id: string) => Promise<void> };
|
||||
}
|
||||
|
||||
beforeEach(() => useSettingsStore.setState({ calendarTimeZone: 'Europe/Berlin' }));
|
||||
afterEach(() => useSettingsStore.setState({ calendarTimeZone: 'auto' }));
|
||||
|
||||
describe.each(occurrences)('changing every occurrence from one of them on Stalwart %s', (_label, version, make) => {
|
||||
it('renames the series and keeps when it happens and its answers', () => {
|
||||
const occurrence = make();
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, series(version), editorUpdates(occurrence, { title: 'Planning, room 2' }), editorOn(occurrence), context,
|
||||
);
|
||||
|
||||
expect(updates.title).toBe('Planning, room 2');
|
||||
for (const key of ['start', 'duration', 'timeZone', 'showWithoutTime', 'recurrenceId', 'participants', 'organizerCalendarAddress', 'recurrenceRules']) {
|
||||
expect(updates).not.toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
it('moves the series from its own start by as much as the occurrence moved, and asks again', () => {
|
||||
const occurrence = make();
|
||||
const { start, end } = editorTimesFromEvent(occurrence);
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, series(version),
|
||||
editorUpdates(occurrence, { start: '2026-10-19T11:30:00' }),
|
||||
editorOn(occurrence, { when: { allDay: false, start: addMinutes(start, 90), end: addMinutes(end, 90) } }),
|
||||
context,
|
||||
);
|
||||
|
||||
expect(updates.start).toBe('2026-10-05T11:30:00');
|
||||
expect(updates).not.toHaveProperty('duration');
|
||||
expect(updates).not.toHaveProperty('timeZone');
|
||||
// Asked again as the series' organizer: the series' answer, not the
|
||||
// occurrence's "declined", goes back to needs-action.
|
||||
expect(statusOf(updates.participants, ORGANIZER)).toBe('accepted');
|
||||
expect(statusOf(updates.participants, ATTENDEE)).toBe('needs-action');
|
||||
});
|
||||
|
||||
it('moves the series to another weekday by the days the occurrence moved', () => {
|
||||
const occurrence = make();
|
||||
const { start, end } = editorTimesFromEvent(occurrence);
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, series(version), editorUpdates(occurrence),
|
||||
editorOn(occurrence, { when: { allDay: false, start: addDays(start, 1), end: addDays(end, 1) } }),
|
||||
context,
|
||||
);
|
||||
|
||||
expect(updates.start).toBe('2026-10-06T10:00:00');
|
||||
});
|
||||
|
||||
it('changes only the length when only the end moved', () => {
|
||||
const occurrence = make();
|
||||
const { start, end } = editorTimesFromEvent(occurrence);
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, series(version), editorUpdates(occurrence, { duration: 'PT1H30M' }),
|
||||
editorOn(occurrence, { when: { allDay: false, start, end: addMinutes(end, 30) } }),
|
||||
context,
|
||||
);
|
||||
|
||||
expect(updates.duration).toBe('PT1H30M');
|
||||
expect(updates).not.toHaveProperty('start');
|
||||
expect(statusOf(updates.participants, ATTENDEE)).toBe('needs-action');
|
||||
});
|
||||
|
||||
it('turns the series all-day on its own first day', () => {
|
||||
const occurrence = make();
|
||||
const day = startOfDay(getEventStartDate(occurrence));
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, series(version),
|
||||
editorUpdates(occurrence, { start: '2026-10-19T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true }),
|
||||
editorOn(occurrence, { when: { allDay: true, start: day, end: day } }),
|
||||
context,
|
||||
);
|
||||
|
||||
expect(updates).toMatchObject({ start: '2026-10-05T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true });
|
||||
});
|
||||
|
||||
it('adds the attendee the user added to the series\' own participants', () => {
|
||||
const occurrence = make();
|
||||
const attendees = [{ name: '', email: ATTENDEE }, { name: 'Anna', email: 'usera@example.org' }];
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, series(version), editorUpdates(occurrence), editorOn(occurrence, { attendees }), context,
|
||||
);
|
||||
|
||||
const participants = updates.participants as Record<string, Participant>;
|
||||
// The series' ids and answers: userb accepted the series.
|
||||
expect(participants.org.calendarAddress).toBe(`mailto:${ORGANIZER}`);
|
||||
expect(participants.b.participationStatus).toBe('accepted');
|
||||
expect(statusOf(participants, 'usera@example.org')).toBe('needs-action');
|
||||
expect(Object.keys(participants)).toHaveLength(3);
|
||||
expect(updates).not.toHaveProperty('organizerCalendarAddress');
|
||||
});
|
||||
|
||||
it('removes the attendee the user removed, and the participants with the last one', () => {
|
||||
const occurrence = make();
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, series(version), editorUpdates(occurrence, { participants: null }), editorOn(occurrence, { attendees: [] }), context,
|
||||
);
|
||||
|
||||
expect(updates.participants).toBeNull();
|
||||
expect(updates.organizerCalendarAddress).toBeNull();
|
||||
});
|
||||
|
||||
it('asks the attendees again when the series gets another rule', () => {
|
||||
const occurrence = make();
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, series(version),
|
||||
editorUpdates(occurrence, { recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 }] }),
|
||||
editorOn(occurrence, { ruleChanged: true }), context,
|
||||
);
|
||||
|
||||
expect(updates).not.toHaveProperty('start');
|
||||
expect(updates.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 }]);
|
||||
expect(statusOf(updates.participants, ATTENDEE)).toBe('needs-action');
|
||||
});
|
||||
|
||||
it('moves an attendee\'s own copy of the series and leaves its participants alone', () => {
|
||||
const occurrence = make();
|
||||
const { start, end } = editorTimesFromEvent(occurrence);
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, { ...series(version), isOrigin: false }, editorUpdates(occurrence, { participants: undefined }),
|
||||
editorOn(occurrence, { when: { allDay: false, start: addHours(start, 1), end: addHours(end, 1) } }),
|
||||
{ userEmail: ATTENDEE, userEmails: [ATTENDEE] },
|
||||
);
|
||||
|
||||
expect(updates.start).toBe('2026-10-05T11:00:00');
|
||||
expect(updates).not.toHaveProperty('participants');
|
||||
});
|
||||
|
||||
// The series in userc's calendar, shared with usera read & write: Stalwart
|
||||
// sends usera's change from userc (isOrigin: true).
|
||||
it('asks again when the user moves the owner\'s series in a calendar shared with them', () => {
|
||||
const occurrence = make();
|
||||
const { start, end } = editorTimesFromEvent(occurrence);
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, { ...series(version), isOrigin: true, isShared: true, accountId: 'f' }, editorUpdates(occurrence),
|
||||
editorOn(occurrence, { when: { allDay: false, start: addHours(start, 1), end: addHours(end, 1) } }),
|
||||
{ userEmail: SHAREE, userEmails: [SHAREE] },
|
||||
);
|
||||
|
||||
expect(updates.start).toBe('2026-10-05T11:00:00');
|
||||
expect(statusOf(updates.participants, ORGANIZER)).toBe('accepted');
|
||||
expect(statusOf(updates.participants, ATTENDEE)).toBe('needs-action');
|
||||
expect(statusOf(updates.participants, SHAREE)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing every occurrence of a series in another time zone', () => {
|
||||
it('moves the series on its own wall clock, whatever zone the calendar shows it in', () => {
|
||||
useSettingsStore.setState({ calendarTimeZone: 'Asia/Tokyo' });
|
||||
// 08:00 in New York is 21:00 in Tokyo; the user moves it to 22:00.
|
||||
const base = {
|
||||
...series('1.0'), start: '2026-10-05T08:00:00', timeZone: 'America/New_York',
|
||||
utcStart: '2026-10-05T12:00:00Z', utcEnd: '2026-10-05T13:00:00Z',
|
||||
} as CalendarEvent;
|
||||
const occurrence = {
|
||||
...base, id: 'maaaaac', baseEventId: 'b', start: '2026-10-19T08:00:00',
|
||||
utcStart: '2026-10-19T12:00:00Z', utcEnd: '2026-10-19T13:00:00Z', recurrenceId: '2026-10-19T08:00:00',
|
||||
} as CalendarEvent;
|
||||
const { start, end } = editorTimesFromEvent(occurrence);
|
||||
expect(start.getHours()).toBe(21);
|
||||
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, base,
|
||||
editorUpdates(occurrence, { start: '2026-10-19T09:00:00' }),
|
||||
editorOn(occurrence, { when: { allDay: false, start: addHours(start, 1), end: addHours(end, 1) } }),
|
||||
context,
|
||||
);
|
||||
|
||||
expect(updates.start).toBe('2026-10-05T09:00:00');
|
||||
expect(updates).not.toHaveProperty('timeZone');
|
||||
});
|
||||
});
|
||||
|
||||
// Weekly on Wednesdays from 9 December, 10:00 in Berlin, five times; userb
|
||||
// accepted. 16 December is excluded, 30 December retitled, and 6 January
|
||||
// moved to 14:00 on its own. 1.0 returns the overrides as PatchObjects; 0.16
|
||||
// as the app wrote them on the base event: the occurrence's time pinned, its
|
||||
// participants copied, and the server's `updated`.
|
||||
function seriesWithOverrides(version: Version): CalendarEvent {
|
||||
const participants = { org: participant(version, ORGANIZER, true, 'accepted'), b: participant(version, ATTENDEE, false, 'accepted') };
|
||||
const written = (start: string, extra: Record<string, unknown> = {}) => ({
|
||||
updated: '2026-11-20T09:00:00Z', start, duration: 'PT1H', participants, ...extra,
|
||||
});
|
||||
return {
|
||||
...series(version),
|
||||
start: '2026-12-09T10:00:00', utcStart: '2026-12-09T09:00:00Z', utcEnd: '2026-12-09T10:00:00Z',
|
||||
participants,
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 }],
|
||||
recurrenceOverrides: version === '1.0'
|
||||
? {
|
||||
'2026-12-16T10:00:00': { excluded: true },
|
||||
'2026-12-30T10:00:00': { title: 'Planning, special' },
|
||||
'2027-01-06T10:00:00': { start: '2027-01-06T14:00:00' },
|
||||
}
|
||||
: {
|
||||
'2026-12-16T10:00:00': { excluded: true },
|
||||
'2026-12-30T10:00:00': written('2026-12-30T10:00:00', { title: 'Planning, special' }),
|
||||
'2027-01-06T10:00:00': written('2027-01-06T14:00:00'),
|
||||
},
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
/** A Berlin wall clock in winter (UTC+1), `hours` later, as the UTC instant the server reports. */
|
||||
const berlinUtc = (wall: string, hours = 0) => `${new Date(Date.parse(`${wall}+01:00`) + hours * 3_600_000).toISOString().slice(0, 19)}Z`;
|
||||
|
||||
/** An occurrence of seriesWithOverrides as the server expands it, on its (own) day and time. */
|
||||
function overriddenOccurrence(version: Version, recurrenceId: string, start = recurrenceId): CalendarEvent {
|
||||
return {
|
||||
...seriesWithOverrides(version), id: 'maaaaad', baseEventId: 'b', start, recurrenceId, recurrenceIdTimeZone: 'Europe/Berlin',
|
||||
utcStart: berlinUtc(start), utcEnd: berlinUtc(start, 1),
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
const moveBy = (occurrence: CalendarEvent, hours: number): Partial<SeriesEdit> => {
|
||||
const { start, end } = editorTimesFromEvent(occurrence);
|
||||
return { when: { allDay: false, start: addHours(start, hours), end: addHours(end, hours) } };
|
||||
};
|
||||
|
||||
// A series moved for every occurrence kept its overrides under the old
|
||||
// recurrence ids: 16 December came back at 11:00, 30 December showed twice
|
||||
// (10:00 "special" and 11:00), in the attendee's copy too (live on 1.0 and
|
||||
// 0.16.23). Each override now goes with its occurrence.
|
||||
describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('overrides of a series moved for every occurrence on Stalwart %s', (_label, version) => {
|
||||
const pinned = (start: string, extra: Record<string, unknown> = {}) => ({ start, duration: 'PT1H', ...extra });
|
||||
|
||||
it('moves every override with its occurrence; one moved on its own keeps its time', () => {
|
||||
const occurrence = overriddenOccurrence(version, '2026-12-23T10:00:00');
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, seriesWithOverrides(version), editorUpdates(occurrence), editorOn(occurrence, moveBy(occurrence, 1)), context,
|
||||
);
|
||||
|
||||
expect(updates.start).toBe('2026-12-09T11:00:00');
|
||||
const overrides = updates.recurrenceOverrides as Record<string, Record<string, unknown>>;
|
||||
expect(Object.keys(overrides).sort()).toEqual(['2026-12-16T11:00:00', '2026-12-30T11:00:00', '2027-01-06T11:00:00']);
|
||||
expect(overrides['2026-12-16T11:00:00']).toEqual({ excluded: true });
|
||||
if (version === '1.0') {
|
||||
expect(overrides['2026-12-30T11:00:00']).toEqual({ title: 'Planning, special' });
|
||||
expect(overrides['2027-01-06T11:00:00']).toEqual({ start: '2027-01-06T14:00:00' });
|
||||
} else {
|
||||
// The time the override only held moves; the answers in it are asked
|
||||
// again with the series'. 6 January stays at 14:00 and keeps them.
|
||||
expect(overrides['2026-12-30T11:00:00']).toMatchObject(pinned('2026-12-30T11:00:00', { title: 'Planning, special' }));
|
||||
expect(statusOf(overrides['2026-12-30T11:00:00'].participants as CalendarEvent['participants'], ATTENDEE)).toBe('needs-action');
|
||||
expect(overrides['2027-01-06T11:00:00']).toMatchObject(pinned('2027-01-06T14:00:00'));
|
||||
expect(statusOf(overrides['2027-01-06T11:00:00'].participants as CalendarEvent['participants'], ATTENDEE)).toBe('accepted');
|
||||
expect(overrides['2026-12-30T11:00:00']).not.toHaveProperty('updated');
|
||||
}
|
||||
});
|
||||
|
||||
it('moves the overrides to the day the series moved to', () => {
|
||||
const occurrence = overriddenOccurrence(version, '2026-12-23T10:00:00');
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, seriesWithOverrides(version), editorUpdates(occurrence), editorOn(occurrence, moveBy(occurrence, 24)), context,
|
||||
);
|
||||
|
||||
expect(updates.start).toBe('2026-12-10T10:00:00');
|
||||
expect(Object.keys(updates.recurrenceOverrides!).sort()).toEqual(['2026-12-17T10:00:00', '2026-12-31T10:00:00', '2027-01-07T10:00:00']);
|
||||
expect(updates.recurrenceOverrides!['2027-01-07T10:00:00']).toMatchObject({ start: '2027-01-06T14:00:00' });
|
||||
});
|
||||
|
||||
it('gives the occurrence the editor was opened on the move made there', () => {
|
||||
// 6 January, at 14:00 on its own, moved to 15:00 for every occurrence.
|
||||
const occurrence = overriddenOccurrence(version, '2027-01-06T10:00:00', '2027-01-06T14:00:00');
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, seriesWithOverrides(version), editorUpdates(occurrence), editorOn(occurrence, moveBy(occurrence, 1)), context,
|
||||
);
|
||||
|
||||
expect(updates.start).toBe('2026-12-09T11:00:00');
|
||||
expect(updates.recurrenceOverrides!['2027-01-06T11:00:00']).toMatchObject({ start: '2027-01-06T15:00:00' });
|
||||
expect(updates.recurrenceOverrides!['2026-12-30T11:00:00']).toMatchObject({ title: 'Planning, special' });
|
||||
});
|
||||
|
||||
it('takes every occurrence along when the series turns all-day', () => {
|
||||
const occurrence = overriddenOccurrence(version, '2026-12-23T10:00:00');
|
||||
const day = startOfDay(getEventStartDate(occurrence));
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, seriesWithOverrides(version),
|
||||
editorUpdates(occurrence, { start: '2026-12-23T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true }),
|
||||
editorOn(occurrence, { when: { allDay: true, start: day, end: day } }), context,
|
||||
);
|
||||
|
||||
expect(updates).toMatchObject({ start: '2026-12-09T00:00:00', showWithoutTime: true });
|
||||
const overrides = updates.recurrenceOverrides as Record<string, Record<string, unknown>>;
|
||||
expect(overrides['2026-12-16T00:00:00']).toEqual({ excluded: true });
|
||||
expect(overrides['2026-12-30T00:00:00']).toMatchObject({ title: 'Planning, special' });
|
||||
for (const key of ['start', 'duration', 'timeZone']) expect(overrides['2026-12-30T00:00:00']).not.toHaveProperty(key);
|
||||
// 6 January had nothing but its own time (on 1.0): with it gone, so is the override.
|
||||
if (version === '1.0') expect(overrides).not.toHaveProperty('2027-01-06T00:00:00');
|
||||
});
|
||||
|
||||
it('drops the overrides of occurrences a new rule no longer has', () => {
|
||||
const occurrence = overriddenOccurrence(version, '2026-12-23T10:00:00');
|
||||
const everyOtherWeek = [{ '@type': 'RecurrenceRule' as const, frequency: 'weekly', interval: 2, count: 5 }];
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, seriesWithOverrides(version), editorUpdates(occurrence, { recurrenceRules: everyOtherWeek }),
|
||||
editorOn(occurrence, { ruleChanged: true }), context,
|
||||
);
|
||||
|
||||
// 9, 23 December and 6 January stay; 16 and 30 December are gone.
|
||||
expect(updates).not.toHaveProperty('start');
|
||||
expect(Object.keys(updates.recurrenceOverrides!)).toEqual(['2027-01-06T10:00:00']);
|
||||
});
|
||||
|
||||
it('leaves the overrides alone when the series only gets a new title', () => {
|
||||
const occurrence = overriddenOccurrence(version, '2026-12-23T10:00:00');
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, seriesWithOverrides(version), editorUpdates(occurrence, { title: 'Planning, room 2' }), editorOn(occurrence), context,
|
||||
);
|
||||
|
||||
expect(updates).not.toHaveProperty('recurrenceOverrides');
|
||||
});
|
||||
|
||||
it('moves them the same way for an occurrence the device expanded', () => {
|
||||
const master = seriesWithOverrides(version);
|
||||
const occurrence = expandRecurringEvents([master], '2026-12-01T00:00:00', '2027-01-31T00:00:00')
|
||||
.find((o) => o.recurrenceId === '2026-12-23T10:00:00')!;
|
||||
const updates = seriesUpdatesFromOccurrence(
|
||||
occurrence, master, editorUpdates(occurrence), editorOn(occurrence, moveBy(occurrence, 1)), context,
|
||||
);
|
||||
|
||||
expect(updates.start).toBe('2026-12-09T11:00:00');
|
||||
expect(Object.keys(updates.recurrenceOverrides!).sort()).toEqual(['2026-12-16T11:00:00', '2026-12-30T11:00:00', '2027-01-06T11:00:00']);
|
||||
});
|
||||
});
|
||||
|
||||
// "This and the following occurrences" left the overrides after the split in
|
||||
// the old series, whose rule no longer reached them: they showed on as
|
||||
// occurrences of it, next to the new series. And the new series got the
|
||||
// whole COUNT again. Six weekly occurrences from 7 December, 4 January
|
||||
// retitled alone, split from 21 December: the old series showed 7 and 14
|
||||
// December and 4 January, the new one ran six times to 25 January (live on
|
||||
// 1.0 and 0.16.23).
|
||||
describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('overrides of a series split from one occurrence on Stalwart %s', (_label, version) => {
|
||||
function sixWeeks(overrides: CalendarEvent['recurrenceOverrides']): CalendarEvent {
|
||||
return {
|
||||
...series(version), start: '2026-12-07T10:00:00', utcStart: '2026-12-07T09:00:00Z', utcEnd: '2026-12-07T10:00:00Z',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 6 }], recurrenceOverrides: overrides,
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
const special = version === '1.0'
|
||||
? { title: 'Planning, special' }
|
||||
: {
|
||||
updated: '2026-11-20T09:00:00Z', start: '2027-01-04T10:00:00', duration: 'PT1H', title: 'Planning, special',
|
||||
participants: { org: participant(version, ORGANIZER, true, 'accepted'), b: participant(version, ATTENDEE, false, 'accepted') },
|
||||
};
|
||||
const occurrenceOf = (master: CalendarEvent, recurrenceId: string) => ({
|
||||
...master, id: 'maaaaae', baseEventId: 'b', start: recurrenceId, recurrenceId, recurrenceIdTimeZone: 'Europe/Berlin',
|
||||
utcStart: berlinUtc(recurrenceId), utcEnd: berlinUtc(recurrenceId, 1),
|
||||
}) as CalendarEvent;
|
||||
|
||||
it('ends the old series with its overrides, and gives the new one those and what is left of the count', async () => {
|
||||
const master = sixWeeks({ '2027-01-04T10:00:00': special } as never);
|
||||
const occurrence = occurrenceOf(master, '2026-12-21T10:00:00');
|
||||
const deps = actions(master);
|
||||
|
||||
await saveOccurrenceEdit('this_and_future', {
|
||||
event: occurrence, updates: editorUpdates(occurrence, { title: 'Planning, room 2' }), editor: editorOn(occurrence),
|
||||
sendScheduling: true, userEmails: [ORGANIZER],
|
||||
}, deps);
|
||||
|
||||
expect(deps.updateEvent).toHaveBeenCalledWith('b', {
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-12-21T09:59:59' }],
|
||||
'recurrenceOverrides/2027-01-04T10:00:00': null,
|
||||
}, { sendSchedulingMessages: true });
|
||||
const [created] = deps.createEvent.mock.calls[0];
|
||||
expect(created.start).toBe('2026-12-21T10:00:00');
|
||||
expect(created.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 4 }]);
|
||||
// What the organizer changed goes along, not the answers the old series
|
||||
// had. 0.16 keeps an override as an event of its own: it gets the new
|
||||
// series' participants, asked again, or the occurrence has none.
|
||||
expect(created.recurrenceOverrides).toEqual({
|
||||
'2027-01-04T10:00:00': version === '1.0'
|
||||
? { title: 'Planning, special' }
|
||||
: { start: '2027-01-04T10:00:00', duration: 'PT1H', title: 'Planning, special', participants: created.participants },
|
||||
});
|
||||
expect(statusOf(created.participants, ATTENDEE)).toBe('needs-action');
|
||||
});
|
||||
|
||||
it('takes an exclusion along, and keeps the overrides before the split', async () => {
|
||||
const master = sixWeeks({ '2026-12-14T10:00:00': special, '2026-12-28T10:00:00': { excluded: true } } as never);
|
||||
const occurrence = occurrenceOf(master, '2026-12-21T10:00:00');
|
||||
const deps = actions(master);
|
||||
|
||||
await saveOccurrenceEdit('this_and_future', {
|
||||
event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: true, userEmails: [ORGANIZER],
|
||||
}, deps);
|
||||
|
||||
const [, patch] = deps.updateEvent.mock.calls[0];
|
||||
expect(Object.keys(patch).sort()).toEqual(['recurrenceOverrides/2026-12-28T10:00:00', 'recurrenceRules']);
|
||||
expect(deps.createEvent.mock.calls[0][0].recurrenceOverrides).toEqual({ '2026-12-28T10:00:00': { excluded: true } });
|
||||
});
|
||||
|
||||
it('keeps the rule the user gave the new series, count and all', async () => {
|
||||
const master = sixWeeks(null);
|
||||
const occurrence = occurrenceOf(master, '2026-12-21T10:00:00');
|
||||
const deps = actions(master);
|
||||
const daily = [{ '@type': 'RecurrenceRule' as const, frequency: 'daily', count: 10 }];
|
||||
|
||||
await saveOccurrenceEdit('this_and_future', {
|
||||
event: occurrence, updates: editorUpdates(occurrence, { recurrenceRules: daily }), editor: editorOn(occurrence, { ruleChanged: true }),
|
||||
sendScheduling: true, userEmails: [ORGANIZER],
|
||||
}, deps);
|
||||
|
||||
expect(deps.createEvent.mock.calls[0][0].recurrenceRules).toEqual(daily);
|
||||
});
|
||||
|
||||
it('keeps an UNTIL as it was', async () => {
|
||||
const master = { ...sixWeeks(null), recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', until: '2027-01-31T23:59:59' }] } as CalendarEvent;
|
||||
const occurrence = occurrenceOf(master, '2026-12-21T10:00:00');
|
||||
const deps = actions(master);
|
||||
|
||||
await saveOccurrenceEdit('this_and_future', {
|
||||
event: occurrence, updates: editorUpdates(occurrence, { title: 'Planning, room 2' }), editor: editorOn(occurrence),
|
||||
sendScheduling: true, userEmails: [ORGANIZER],
|
||||
}, deps);
|
||||
|
||||
expect(deps.createEvent.mock.calls[0][0].recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly', until: '2027-01-31T23:59:59' }]);
|
||||
});
|
||||
|
||||
// 18 December on, of a weekly series on Fridays: the retitled 1 January
|
||||
// stayed as an occurrence of the old series.
|
||||
it('deletes the overrides from the occurrence on with the rest of the series', async () => {
|
||||
const master = {
|
||||
...sixWeeks({ '2027-01-01T10:00:00': { ...special, ...(version === '1.0' ? {} : { start: '2027-01-01T10:00:00' }) } } as never),
|
||||
start: '2026-12-04T10:00:00',
|
||||
} as CalendarEvent;
|
||||
const deps = actions(master);
|
||||
|
||||
await deleteOccurrence('this_and_future', { event: occurrenceOf(master, '2026-12-18T10:00:00'), userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(deps.updateEvent).toHaveBeenCalledWith('b', {
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-12-18T09:59:59' }],
|
||||
'recurrenceOverrides/2027-01-01T10:00:00': null,
|
||||
}, { sendSchedulingMessages: true });
|
||||
expect(deps.deleteEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// The move of an "all" edit was measured on the calendar'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 calendar 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 { start: from } = editorTimesFromEvent(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-26T11:00:00' }),
|
||||
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 calendar'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, change),
|
||||
editor: editorOn(occurrence, hours ? moveBy(occurrence, hours) : {}),
|
||||
sendScheduling: true, userEmails: [ORGANIZER],
|
||||
}, deps);
|
||||
return deps.createEvent.mock.calls[0][0];
|
||||
}
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveOccurrenceEdit for every occurrence', () => {
|
||||
it('writes the series\' own change to its base event on Stalwart 1.0', async () => {
|
||||
const occurrence = serverOccurrence('1.0');
|
||||
const deps = actions({ ...series('1.0'), recurrenceOverrides: null });
|
||||
|
||||
const saved = await saveOccurrenceEdit('all', {
|
||||
event: occurrence,
|
||||
updates: editorUpdates(occurrence, { title: 'Planning, room 2' }),
|
||||
editor: editorOn(occurrence),
|
||||
sendScheduling: true,
|
||||
userEmails: [ORGANIZER],
|
||||
}, deps);
|
||||
|
||||
expect(saved).toBe(true);
|
||||
expect(deps.getMasterEvent).toHaveBeenCalledWith(occurrence);
|
||||
// What the user changed, and only that: the rule the editor wrote as it
|
||||
// was stays the series' own.
|
||||
expect(deps.updateEvent).toHaveBeenCalledWith('b', { title: 'Planning, room 2' }, { sendSchedulingMessages: true });
|
||||
});
|
||||
|
||||
it('reports a series it cannot find', async () => {
|
||||
const occurrence = serverOccurrence('1.0');
|
||||
const deps = actions(null);
|
||||
|
||||
const saved = await saveOccurrenceEdit('all', {
|
||||
event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), userEmails: [ORGANIZER],
|
||||
}, deps);
|
||||
|
||||
expect(saved).toBe(false);
|
||||
expect(deps.updateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// "This and the following occurrences" ends the series before the
|
||||
// occurrence and makes a new series of the rest. The new series kept the
|
||||
// answers, and Stalwart invites to a new event only attendees without one:
|
||||
// everyone who had answered lost the following occurrences without a word.
|
||||
describe.each(occurrences)('saving this and the following occurrences on Stalwart %s', (_label, version, make) => {
|
||||
const truncated = [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-10-19T09:59:59' }];
|
||||
// The series ends before 19 October, and userb's answer to 19 October
|
||||
// (an override) goes with it.
|
||||
const truncation = { recurrenceRules: truncated, 'recurrenceOverrides/2026-10-19T10:00:00': null };
|
||||
|
||||
it('tells the attendees the series ends there, and asks all of them to the new one', async () => {
|
||||
const occurrence = make();
|
||||
const deps = actions(series(version));
|
||||
|
||||
const saved = await saveOccurrenceEdit('this_and_future', {
|
||||
event: occurrence, updates: editorUpdates(occurrence, { title: 'Planning, room 2' }), editor: editorOn(occurrence),
|
||||
calendarId: 'cal', sendScheduling: true, userEmails: [ORGANIZER],
|
||||
}, deps);
|
||||
|
||||
expect(saved).toBe(true);
|
||||
expect(deps.updateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(deps.updateEvent).toHaveBeenCalledWith('b', truncation, { sendSchedulingMessages: true });
|
||||
const [created, calendarId, options] = deps.createEvent.mock.calls[0];
|
||||
expect([calendarId, options]).toEqual(['cal', { sendSchedulingMessages: true }]);
|
||||
// Five occurrences in all, two of them before 19 October.
|
||||
expect(created).toMatchObject({
|
||||
title: 'Planning, room 2', start: '2026-10-19T10:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 3 }],
|
||||
});
|
||||
// The new series asks userb again for every occurrence: the old answer
|
||||
// to 19 October does not come along. The store puts it in the calendar.
|
||||
for (const key of ['id', 'uid', 'recurrenceId', 'recurrenceOverrides', 'calendarIds', 'isOrigin']) expect(created).not.toHaveProperty(key);
|
||||
expect(statusOf(created.participants, ORGANIZER)).toBe('accepted');
|
||||
expect(statusOf(created.participants, ATTENDEE)).toBe('needs-action');
|
||||
});
|
||||
|
||||
// A store that answers a failure with nothing: 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('b', {
|
||||
recurrenceRules: series(version).recurrenceRules,
|
||||
recurrenceOverrides: series(version).recurrenceOverrides,
|
||||
}, { sendSchedulingMessages: 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.'));
|
||||
|
||||
await expect(saveOccurrenceEdit('this_and_future', {
|
||||
event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: true, userEmails: [ORGANIZER],
|
||||
}, deps)).rejects.toThrow('more than 20');
|
||||
|
||||
expect(deps.updateEvent).toHaveBeenLastCalledWith('b', expect.objectContaining({
|
||||
recurrenceRules: series(version).recurrenceRules,
|
||||
}), { sendSchedulingMessages: true });
|
||||
});
|
||||
|
||||
it('ends an attendee\'s own copy quietly', async () => {
|
||||
const occurrence = make();
|
||||
const deps = actions({ ...series(version), isOrigin: false });
|
||||
|
||||
await saveOccurrenceEdit('this_and_future', {
|
||||
event: occurrence, updates: editorUpdates(occurrence, { participants: undefined }), editor: editorOn(occurrence),
|
||||
sendScheduling: false, userEmails: [ATTENDEE],
|
||||
}, deps);
|
||||
|
||||
expect(deps.updateEvent).toHaveBeenCalledWith('b', truncation, { sendSchedulingMessages: false });
|
||||
expect(deps.createEvent.mock.calls[0][2]).toEqual({ sendSchedulingMessages: false });
|
||||
});
|
||||
|
||||
it('ends the series quietly when the user sends no invitations', async () => {
|
||||
const occurrence = make();
|
||||
const deps = actions(series(version));
|
||||
|
||||
await saveOccurrenceEdit('this_and_future', {
|
||||
event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: false, userEmails: [ORGANIZER],
|
||||
}, deps);
|
||||
|
||||
expect(deps.updateEvent).toHaveBeenCalledWith('b', truncation, { sendSchedulingMessages: false });
|
||||
});
|
||||
|
||||
// The series in userc's calendar, shared with usera read & write: the
|
||||
// split has to tell the attendees and ask them to the rest.
|
||||
it('tells the attendees and asks them to the new series when the user splits the owner\'s series in a shared calendar', async () => {
|
||||
const occurrence = make();
|
||||
const deps = actions({ ...series(version), isOrigin: true, isShared: true, accountId: 'f' });
|
||||
|
||||
await saveOccurrenceEdit('this_and_future', {
|
||||
event: occurrence, updates: editorUpdates(occurrence, { title: 'Planning, room 2' }), editor: editorOn(occurrence),
|
||||
sendScheduling: true, userEmails: [SHAREE],
|
||||
}, deps);
|
||||
|
||||
expect(deps.updateEvent).toHaveBeenCalledWith('b', truncation, { sendSchedulingMessages: true });
|
||||
const [created, , options] = deps.createEvent.mock.calls[0];
|
||||
expect(options).toEqual({ sendSchedulingMessages: true });
|
||||
expect(statusOf(created.participants, ORGANIZER)).toBe('accepted');
|
||||
expect(statusOf(created.participants, ATTENDEE)).toBe('needs-action');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the new series from this and the following occurrences', () => {
|
||||
it('starts at the occurrence and leaves the old series\' identity behind', () => {
|
||||
const occurrence = serverOccurrence('1.0');
|
||||
const data = followingSeriesData(occurrence, series('1.0'), { title: 'Moved on', recurrenceId: occurrence.recurrenceId }, null);
|
||||
|
||||
expect(data).toMatchObject({ title: 'Moved on', start: '2026-10-19T10:00:00', recurrenceRules: null, timeZone: 'Europe/Berlin' });
|
||||
expect(data).not.toHaveProperty('recurrenceId');
|
||||
// Without participants in the change there are none to ask.
|
||||
expect(data).not.toHaveProperty('participants');
|
||||
});
|
||||
|
||||
it('leaves the calendar\'s default reminders to the switch', () => {
|
||||
const occurrence = serverOccurrence('1.0');
|
||||
const defaults = { ...series('1.0'), useDefaultAlerts: true, alerts: { d1: { '@type': 'Alert' } } } as unknown as CalendarEvent;
|
||||
|
||||
const data = followingSeriesData(occurrence, defaults, {}, defaults.recurrenceRules!);
|
||||
|
||||
expect(data.useDefaultAlerts).toBe(true);
|
||||
expect(data).not.toHaveProperty('alerts');
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(occurrences)('saving one occurrence on Stalwart %s', (_label, _version, make) => {
|
||||
it('hands the change to the store for that occurrence', async () => {
|
||||
const occurrence = make();
|
||||
const deps = actions(series('1.0'));
|
||||
const updates = editorUpdates(occurrence, { title: 'Planning, room 2' });
|
||||
|
||||
await saveOccurrenceEdit('this', { event: occurrence, updates, sendScheduling: true, userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(deps.updateEvent).toHaveBeenCalledWith(occurrence.id, updates, { sendSchedulingMessages: true });
|
||||
expect(deps.getMasterEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(occurrences)('deleting part of a series from one occurrence on Stalwart %s', (_label, version, make) => {
|
||||
it('deletes that occurrence alone through the store', async () => {
|
||||
const occurrence = make();
|
||||
const deps = actions(series(version));
|
||||
|
||||
await deleteOccurrence('this', { event: occurrence, userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(deps.deleteEvent).toHaveBeenCalledWith(occurrence.id);
|
||||
});
|
||||
|
||||
it('ends the series before it, telling the attendees', async () => {
|
||||
const occurrence = make();
|
||||
const deps = actions(series(version));
|
||||
|
||||
await deleteOccurrence('this_and_future', { event: occurrence, userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(deps.updateEvent).toHaveBeenCalledWith('b', {
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-10-19T09:59:59' }],
|
||||
'recurrenceOverrides/2026-10-19T10:00:00': null,
|
||||
}, { sendSchedulingMessages: true });
|
||||
expect(deps.deleteEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ends an attendee\'s copy quietly', async () => {
|
||||
const occurrence = make();
|
||||
const deps = actions({ ...series(version), isOrigin: false });
|
||||
|
||||
await deleteOccurrence('this_and_future', { event: occurrence, userEmails: [ATTENDEE] }, deps);
|
||||
|
||||
expect(deps.updateEvent.mock.calls[0][2]).toEqual({ sendSchedulingMessages: false });
|
||||
});
|
||||
|
||||
it('deletes the series for every occurrence', async () => {
|
||||
const deps = actions(series(version));
|
||||
|
||||
await deleteOccurrence('all', { event: make(), userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(deps.deleteEvent).toHaveBeenCalledWith('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ending an all-day series', () => {
|
||||
it('ends it the day before the occurrence', async () => {
|
||||
const master = {
|
||||
...series('0.16'), start: '2026-10-05T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true,
|
||||
utcStart: undefined, utcEnd: undefined, recurrenceOverrides: null,
|
||||
} as unknown as CalendarEvent;
|
||||
const occurrence = expandRecurringEvents([master], '2026-10-01T00:00:00', '2026-11-30T00:00:00')
|
||||
.find((o) => o.recurrenceId === '2026-10-19T00:00:00')!;
|
||||
const deps = actions(master);
|
||||
|
||||
await deleteOccurrence('this_and_future', { event: occurrence, userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(deps.updateEvent.mock.calls[0][1]).toEqual({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-10-18T23:59:59' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// An invitation to one occurrence alone: the server keeps it as an event of
|
||||
// its own, with a recurrence id and no series to find. The store hands it
|
||||
// back as its own "series".
|
||||
describe('one occurrence the server keeps as its own event', () => {
|
||||
const lone = () => ({
|
||||
...serverOccurrence('1.0'), id: 'lone', baseEventId: undefined, recurrenceRules: null, recurrenceOverrides: null,
|
||||
}) as unknown as CalendarEvent;
|
||||
|
||||
it.each(['this', 'this_and_future', 'all'] as const)('is itself what changes, and what is deleted, for "%s"', async (scope) => {
|
||||
const deps = actions(lone());
|
||||
|
||||
await saveOccurrenceEdit(scope, { event: lone(), updates: { title: 'Mine' }, userEmails: [ATTENDEE] }, deps);
|
||||
await deleteOccurrence(scope, { event: lone(), userEmails: [ATTENDEE] }, deps);
|
||||
|
||||
expect(deps.updateEvent.mock.calls).toEqual([['lone', { title: 'Mine' }, { sendSchedulingMessages: undefined }]]);
|
||||
expect(deps.deleteEvent.mock.calls).toEqual([['lone']]);
|
||||
expect(deps.createEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -511,23 +511,31 @@ export function seriesParticipantFields(
|
||||
}
|
||||
|
||||
/**
|
||||
* `participants` with every attendee but the organizer asked anew
|
||||
* (needs-action). For a new series cut off an existing one ("This and
|
||||
* following events"): Stalwart sends a new event only to attendees who have
|
||||
* not answered, so an answer carried over would keep it from them.
|
||||
* `event`'s participants with every attendee asked to answer again - back at
|
||||
* needs-action - and the organizer's entries as they are. What its organizer
|
||||
* does when an event moves (RFC 5546), and what a new event made from it
|
||||
* needs: Stalwart sends a new event, and a new override of one occurrence,
|
||||
* only to attendees without an answer (PARTSTAT absent or NEEDS-ACTION), and
|
||||
* a change to an existing one carries each attendee's stored answer. Null
|
||||
* when no attendee has answered.
|
||||
*/
|
||||
export function askAttendeesAgain(
|
||||
participants: Record<string, Participant>,
|
||||
organizerEmails: string[] = [],
|
||||
): Record<string, Participant> {
|
||||
const organizers = new Set(organizerEmails.map((address) => address.trim().toLowerCase()));
|
||||
const result: Record<string, Participant> = {};
|
||||
for (const [id, p] of Object.entries(participants)) {
|
||||
export function participantsAskedAgain(
|
||||
event: Partial<Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress' | 'replyTo'>>,
|
||||
): Record<string, Participant> | null {
|
||||
const organizers = getEventOrganizerEmails(event);
|
||||
let asked = false;
|
||||
const participants: Record<string, Participant> = {};
|
||||
for (const [id, p] of Object.entries(event.participants ?? {})) {
|
||||
const address = getParticipantEmail(p).trim().toLowerCase();
|
||||
const organizer = !!p.roles?.owner || (!!address && organizers.has(address));
|
||||
result[id] = organizer ? p : { ...p, participationStatus: 'needs-action' };
|
||||
const organizer = !!p.roles?.owner || (!!address && organizers.includes(address));
|
||||
if (organizer || !p.participationStatus || p.participationStatus === 'needs-action') {
|
||||
participants[id] = p;
|
||||
continue;
|
||||
}
|
||||
participants[id] = { ...p, participationStatus: 'needs-action' };
|
||||
asked = true;
|
||||
}
|
||||
return result;
|
||||
return asked ? participants : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,6 +66,37 @@ export function expandRecurringEvents(
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many occurrences `rule` gives a series that starts at `start` before
|
||||
* `before`, both LocalDateTimes on the series' own wall clock, its start
|
||||
* included: how much of the rule's COUNT the part of the series before
|
||||
* `before` uses up (the start counts as the first occurrence, RFC 5545).
|
||||
*/
|
||||
export function occurrencesBefore(start: string, rule: RecurrenceRule, before: string): number {
|
||||
const from = parseISO(start);
|
||||
const until = parseISO(before);
|
||||
if (isNaN(from.getTime()) || isNaN(until.getTime()) || until <= from) return 0;
|
||||
return generateDates(from, rule, from, until, true, Infinity).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a series that starts at `start` and repeats by `rules` has an
|
||||
* occurrence at `recurrenceId`, both LocalDateTimes on its own wall clock.
|
||||
* Its start always is one.
|
||||
*/
|
||||
export function seriesOccursAt(
|
||||
start: string,
|
||||
rules: RecurrenceRule[] | null | undefined,
|
||||
recurrenceId: string,
|
||||
): boolean {
|
||||
const from = parseISO(start);
|
||||
const at = parseISO(recurrenceId);
|
||||
if (isNaN(from.getTime()) || isNaN(at.getTime())) return false;
|
||||
if (at.getTime() === from.getTime()) return true;
|
||||
const until = new Date(at.getTime() + 1000);
|
||||
return (rules ?? []).some((rule) => generateDates(from, rule, at, until).some((date) => date.getTime() === at.getTime()));
|
||||
}
|
||||
|
||||
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
|
||||
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
|
||||
}
|
||||
@@ -398,6 +429,7 @@ function generateDates(
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
includeStartDate = true,
|
||||
maxOccurrences = 500,
|
||||
): Date[] {
|
||||
const rule = addImplicitByX(rawRule, eventStart);
|
||||
const dates: Date[] = [];
|
||||
@@ -413,11 +445,10 @@ function generateDates(
|
||||
: fastForwardToRange(eventStart, rule.frequency, interval, rangeStart);
|
||||
|
||||
const maxIterations = 2000;
|
||||
// Cap on *emitted* (in-range) dates. This must not count occurrences
|
||||
// before rangeStart, otherwise a series started long ago (e.g. a daily
|
||||
// event from two years back) exhausts the budget before reaching the
|
||||
// visible range and silently renders nothing.
|
||||
const maxOccurrences = 500;
|
||||
// `maxOccurrences` caps the *emitted* (in-range) dates. It must not count
|
||||
// occurrences before rangeStart, otherwise a series started long ago (e.g.
|
||||
// a daily event from two years back) exhausts the budget before reaching
|
||||
// the visible range and silently renders nothing.
|
||||
let iterations = 0;
|
||||
|
||||
while (iterations++ < maxIterations) {
|
||||
|
||||
@@ -114,7 +114,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>)
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { CalendarEvent, RecurrenceRule } from '../api/types';
|
||||
import {
|
||||
askAttendeesAgain,
|
||||
getEventOrganizerEmails,
|
||||
seriesParticipantFields,
|
||||
userOrganizes,
|
||||
type Attendee,
|
||||
type ParticipantSaveContext,
|
||||
} from './calendar-participants';
|
||||
import type { CalendarEvent } from '../api/types';
|
||||
|
||||
// The scope edits themselves ("this", "this and following", "all") are in
|
||||
// lib/recurrence-scope-edit.ts.
|
||||
|
||||
/** Identity / whole-series keys that must never go into a single-occurrence override. */
|
||||
export const RECURRENCE_OVERRIDE_IMMUTABLE_KEYS = [
|
||||
@@ -31,198 +25,3 @@ export function isRecurringSeriesMember(
|
||||
): boolean {
|
||||
return !!event.recurrenceRules?.length || !!event.recurrenceId;
|
||||
}
|
||||
|
||||
/** The original slot of an occurrence as a local wall-clock Date. */
|
||||
export function occurrenceSlotDate(
|
||||
event: Pick<CalendarEvent, 'recurrenceId' | 'start'>,
|
||||
): Date {
|
||||
// recurrenceId is either "yyyy-MM-dd" (all-day) or the occurrence instant
|
||||
// as UTC ISO; parseISO treats a date-only value as local midnight, which is
|
||||
// what the RRULE `until` below needs. `new Date("yyyy-MM-dd")` would read
|
||||
// it as UTC and shift the day in any non-UTC zone.
|
||||
return parseISO(event.recurrenceId || event.start);
|
||||
}
|
||||
|
||||
/**
|
||||
* "This and following": end the master's rules one second before the chosen
|
||||
* occurrence. `count` is dropped so the truncated rule is bounded by `until`
|
||||
* alone (a rule can't carry both). Mirrors webmail's truncateRecurrenceAtEvent.
|
||||
*/
|
||||
export function truncateRecurrenceRules(
|
||||
rules: RecurrenceRule[] | undefined | null,
|
||||
occurrence: Pick<CalendarEvent, 'recurrenceId' | 'start'>,
|
||||
): RecurrenceRule[] {
|
||||
const untilDate = occurrenceSlotDate(occurrence);
|
||||
untilDate.setSeconds(untilDate.getSeconds() - 1);
|
||||
const until = format(untilDate, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
return (rules || []).map((rule) => {
|
||||
const { count: _count, ...rest } = rule;
|
||||
return { ...rest, until };
|
||||
});
|
||||
}
|
||||
|
||||
/** What an editor save changed about when the event happens. */
|
||||
export interface ScheduleChanges {
|
||||
/** Its start, end or all-day. */
|
||||
timesMoved?: boolean;
|
||||
/** Its recurrence rule. */
|
||||
ruleChanged?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates for the "All events" scope. The editor seeds its date fields from
|
||||
* the tapped occurrence, so an unchanged `start` must not be written to the
|
||||
* master (that would move the whole series onto the occurrence's date and
|
||||
* make the earlier occurrences vanish). When the user did move the occurrence,
|
||||
* the same delta is applied to the master's start instead. With
|
||||
* `changes.timesMoved` false none of the occurrence's times reach the master
|
||||
* - a moved or resized occurrence would move or resize every one - and with
|
||||
* `changes.ruleChanged` false neither does the rule the editor wrote back.
|
||||
*/
|
||||
export function buildAllScopeUpdates(
|
||||
updates: Partial<CalendarEvent>,
|
||||
occurrence: Pick<CalendarEvent, 'start' | 'recurrenceId'>,
|
||||
master: Pick<CalendarEvent, 'start'>,
|
||||
changes: ScheduleChanges = {},
|
||||
): Partial<CalendarEvent> {
|
||||
const out: Partial<CalendarEvent> = { ...updates };
|
||||
delete (out as Record<string, unknown>).recurrenceId;
|
||||
if (changes.ruleChanged === false) delete out.recurrenceRules;
|
||||
if (changes.timesMoved === false) {
|
||||
delete out.start;
|
||||
delete out.duration;
|
||||
delete out.timeZone;
|
||||
delete out.showWithoutTime;
|
||||
return out;
|
||||
}
|
||||
if (!out.start) return out;
|
||||
if (!occurrence.recurrenceId) return out; // editing the master itself
|
||||
const nextStart = parseISO(out.start);
|
||||
const occurrenceStart = parseISO(occurrence.start);
|
||||
const masterStart = parseISO(master.start);
|
||||
if (
|
||||
isNaN(nextStart.getTime()) ||
|
||||
isNaN(occurrenceStart.getTime()) ||
|
||||
isNaN(masterStart.getTime())
|
||||
) {
|
||||
delete out.start;
|
||||
return out;
|
||||
}
|
||||
const delta = nextStart.getTime() - occurrenceStart.getTime();
|
||||
if (delta === 0) {
|
||||
delete out.start;
|
||||
return out;
|
||||
}
|
||||
const shifted = new Date(masterStart.getTime() + delta);
|
||||
out.start = format(shifted, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The new series created for "This and following": the master's series-level
|
||||
* fields, the original (untruncated) rules, the user's edits on top, and the
|
||||
* occurrence's slot as the start unless the user moved it.
|
||||
*/
|
||||
export function buildFutureSeriesData(
|
||||
master: CalendarEvent,
|
||||
originalRules: RecurrenceRule[] | null,
|
||||
occurrence: Pick<CalendarEvent, 'start' | 'recurrenceId'>,
|
||||
updates: Partial<CalendarEvent>,
|
||||
): Partial<CalendarEvent> {
|
||||
const occurrenceStart = occurrence.start;
|
||||
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,
|
||||
showWithoutTime: master.showWithoutTime,
|
||||
participants: master.participants,
|
||||
organizerCalendarAddress: master.organizerCalendarAddress,
|
||||
alerts: master.alerts,
|
||||
useDefaultAlerts: master.useDefaultAlerts,
|
||||
locations: master.locations,
|
||||
virtualLocations: master.virtualLocations,
|
||||
recurrenceRules: originalRules ?? undefined,
|
||||
...updates,
|
||||
start: updates.start || occurrenceStart,
|
||||
};
|
||||
const raw = data as Record<string, unknown>;
|
||||
delete raw.id;
|
||||
delete raw.uid;
|
||||
delete raw.recurrenceId;
|
||||
delete raw.recurrenceOverrides;
|
||||
delete raw.excludedRecurrenceRules;
|
||||
delete raw.utcStart;
|
||||
delete raw.utcEnd;
|
||||
delete raw.originalId;
|
||||
delete raw.baseEventId;
|
||||
delete raw.recurrenceIdTimeZone;
|
||||
delete raw.originalCalendarIds;
|
||||
delete raw.accountId;
|
||||
delete raw.isShared;
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (raw[key] === undefined) delete raw[key];
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** An editor save of one occurrence, for the scope it is applied to. */
|
||||
export interface SeriesEdit extends ScheduleChanges {
|
||||
/** The attendee rows the user left. */
|
||||
attendees: Attendee[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The update of a whole series ("All events") from an editor opened on one of
|
||||
* its occurrences: its times only when the user moved the occurrence (and
|
||||
* then by the same delta), its rule only when the user changed it, and its
|
||||
* participants from the series' own list with the attendees the user added
|
||||
* or removed (seriesParticipantFields) - never the occurrence's list, whose
|
||||
* answers are that day's. A series the user organizes and moves asks its
|
||||
* attendees again.
|
||||
*/
|
||||
export function buildAllScopeEdit(
|
||||
updates: Partial<CalendarEvent>,
|
||||
occurrence: CalendarEvent,
|
||||
master: CalendarEvent,
|
||||
edit: SeriesEdit,
|
||||
context: ParticipantSaveContext,
|
||||
): Partial<CalendarEvent> {
|
||||
const out = buildAllScopeUpdates(updates, occurrence, master, edit);
|
||||
delete out.participants;
|
||||
delete out.organizerCalendarAddress;
|
||||
delete out.replyTo;
|
||||
return {
|
||||
...out,
|
||||
...seriesParticipantFields(master, occurrence, edit.attendees, {
|
||||
...context,
|
||||
rescheduled: !!edit.timesMoved || !!edit.ruleChanged,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The new series of "This and following events", as buildFutureSeriesData
|
||||
* makes it. It is a new event (a new UID) to its attendees, and Stalwart
|
||||
* sends a new event only to attendees who have not answered: when the user
|
||||
* organizes it, everyone but the organizer is asked anew. Whether they do is
|
||||
* the old series' to say: the new one carries no `isOrigin` yet, and the
|
||||
* owner's meeting in a calendar shared with the user is theirs to change
|
||||
* (userOrganizes).
|
||||
*/
|
||||
export function buildFutureSeriesEdit(
|
||||
master: CalendarEvent,
|
||||
originalRules: RecurrenceRule[] | null,
|
||||
occurrence: Pick<CalendarEvent, 'start' | 'recurrenceId'>,
|
||||
updates: Partial<CalendarEvent>,
|
||||
userEmails: string[],
|
||||
): Partial<CalendarEvent> {
|
||||
const data = buildFutureSeriesData(master, originalRules, occurrence, updates);
|
||||
if (data.participants && userOrganizes({ ...data, isOrigin: master.isOrigin }, userEmails)) {
|
||||
data.participants = askAttendeesAgain(data.participants, getEventOrganizerEmails(data));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,806 @@
|
||||
/**
|
||||
* Saving an event editor change made on one occurrence of a series for the
|
||||
* part of the series the user picks in the recurrence scope dialog - that
|
||||
* occurrence, it and the ones after it, or all of them - and deleting that
|
||||
* part. Port of the webmail's lib/recurrence-scope-edit.ts, on the calendar
|
||||
* store's actions.
|
||||
*/
|
||||
|
||||
import { differenceInCalendarDays } from 'date-fns';
|
||||
import type { CalendarEvent, RecurrenceRule } from '../api/types';
|
||||
import type { RecurrenceEditScope } from '../components/calendar/RecurrenceScopeDialog';
|
||||
import { getEventStartDate, getPrimaryCalendarId } from './calendar-utils';
|
||||
import {
|
||||
participantsAskedAgain,
|
||||
seriesParticipantFields,
|
||||
userOrganizes,
|
||||
type Attendee,
|
||||
type ParticipantSaveContext,
|
||||
} from './calendar-participants';
|
||||
import { editorTimesChanged, type EditorTimes } from './event-editor-times';
|
||||
import {
|
||||
buildOccurrencePatch,
|
||||
isServerRecurrenceInstance,
|
||||
parseDurationSeconds,
|
||||
resolveOverrideKey,
|
||||
stableJson,
|
||||
} from './recurrence-instances';
|
||||
import { occurrencesBefore, seriesOccursAt } from './recurrence-expansion';
|
||||
import { recurrenceRulesEqual } from './recurrence';
|
||||
import {
|
||||
formatWallClock,
|
||||
fromDisplayDate,
|
||||
getEffectiveTimeZone,
|
||||
isValidTimeZone,
|
||||
localDateTimeToInstant,
|
||||
} from './calendar-timezone';
|
||||
|
||||
/** What the event editor had when it saved, besides the event data it wrote. */
|
||||
export interface SeriesEdit {
|
||||
/** The attendee rows the user left; the organizer is not among them. */
|
||||
attendees: Attendee[];
|
||||
/** When the event happens in the editor: display dates, the last day of an all-day event. */
|
||||
when: EditorTimes;
|
||||
/** The user changed the recurrence rule: a series then happens on other days. */
|
||||
ruleChanged?: boolean;
|
||||
}
|
||||
|
||||
const WHEN_KEYS = ['start', 'duration', 'timeZone', 'showWithoutTime'] as const;
|
||||
type SeriesWhen = Partial<Pick<CalendarEvent, typeof WHEN_KEYS[number]>>;
|
||||
|
||||
/** A display date's wall clock to the minute (the editor shows no seconds), as milliseconds. */
|
||||
function wallClockMinutesMs(date: Date): number {
|
||||
return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes());
|
||||
}
|
||||
|
||||
/** A LocalDateTime ("YYYY-MM-DDTHH:mm:ss") as its wall clock in milliseconds, read as UTC; null when it does not parse. */
|
||||
function wallClockMs(value: string): number | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
|
||||
if (!match) return null;
|
||||
const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0));
|
||||
const wall = new Date(0);
|
||||
wall.setUTCFullYear(year, month - 1, day);
|
||||
wall.setUTCHours(hour, minute, second, 0);
|
||||
return wall.getTime();
|
||||
}
|
||||
|
||||
/** A LocalDateTime ("YYYY-MM-DDTHH:mm:ss") moved by `ms` on its own wall clock. */
|
||||
function shiftLocalDateTime(value: string, ms: number): string {
|
||||
const wall = wallClockMs(value);
|
||||
return wall === null ? value : new Date(wall + ms).toISOString().slice(0, 19);
|
||||
}
|
||||
|
||||
/** `value` moved on the wall clock by as much as `from` moved to `to`. */
|
||||
function movedLike(value: string, from: string, to: string): string {
|
||||
const [before, after] = [wallClockMs(from), wallClockMs(to)];
|
||||
return before === null || after === null ? value : shiftLocalDateTime(value, after - before);
|
||||
}
|
||||
|
||||
/** A LocalDateTime read as the wall clock in `fromZone`, as the wall clock in `toZone` at the same instant. */
|
||||
function moveLocalDateTime(value: string, fromZone: string, toZone: string): string {
|
||||
if (fromZone === toZone || !isValidTimeZone(toZone)) return value;
|
||||
const instant = localDateTimeToInstant(value, fromZone);
|
||||
return instant ? formatWallClock(instant, toZone) : value;
|
||||
}
|
||||
|
||||
type OverrideMap = NonNullable<CalendarEvent['recurrenceOverrides']>;
|
||||
type OverrideEntry = Record<string, unknown>;
|
||||
|
||||
/** Where a series' occurrences are: what the keys of its overrides, their recurrence ids, are. */
|
||||
interface SeriesFrame {
|
||||
start: string;
|
||||
timeZone?: string | null;
|
||||
showWithoutTime: boolean;
|
||||
recurrenceRules: CalendarEvent['recurrenceRules'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The key an override of the series `from` keeps in the series `to`, or
|
||||
* null when `to` has no such occurrence any more. It moves with its
|
||||
* occurrence, by as much as `anchor` - the series' start, or where a new
|
||||
* series takes over - moved to `to.start` (to the same day at the new time
|
||||
* when the series turns all-day or timed). A rule that pins the occurrence
|
||||
* where it was - a weekday rule the start moved away from - keeps the key,
|
||||
* and an occurrence the new rule does not give is gone. A date the override
|
||||
* adds to the series, which no rule gives, moves along.
|
||||
*/
|
||||
function overrideKeyIn(key: string, from: SeriesFrame, to: SeriesFrame, anchor = from.start): string | null {
|
||||
const shifted = movedLike(key, anchor, to.start);
|
||||
const moved = to.showWithoutTime ? `${shifted.slice(0, 10)}T00:00:00` : shifted;
|
||||
if (!seriesOccursAt(from.start, from.recurrenceRules, key)) return moved;
|
||||
if (seriesOccursAt(to.start, to.recurrenceRules, moved)) return moved;
|
||||
if (moved !== key && seriesOccursAt(to.start, to.recurrenceRules, key)) return key;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `entry` with its attendees asked again (participantsAskedAgain): a whole
|
||||
* participants map (0.16) with their answers back at needs-action, a
|
||||
* pointer at one answer (1.0 returns an override as a PatchObject) left out,
|
||||
* so the series' answer - asked again - counts.
|
||||
*/
|
||||
function withAnswersAskedAgain(entry: OverrideEntry, series: CalendarEvent): OverrideEntry {
|
||||
const organizer = { organizerCalendarAddress: series.organizerCalendarAddress, replyTo: series.replyTo };
|
||||
const result: OverrideEntry = {};
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
if (/^participants\/[^/]+\/participationStatus$/.test(key)) continue;
|
||||
const whole = key === 'participants' || /^participants\/[^/]+$/.test(key);
|
||||
if (whole && value && typeof value === 'object') {
|
||||
const participants = key === 'participants'
|
||||
? value as CalendarEvent['participants']
|
||||
: { p: value } as unknown as CalendarEvent['participants'];
|
||||
const asked = participantsAskedAgain({ ...organizer, participants });
|
||||
result[key] = asked ? (key === 'participants' ? asked : asked.p) : value;
|
||||
continue;
|
||||
}
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* `entry` with the participants of a new series, `participants`: pointers
|
||||
* into the old ones (1.0 returns an override as a PatchObject) dropped, so
|
||||
* the series' count; a whole map (0.16) replaced by the series', or dropped
|
||||
* with them. 0.16 keeps an override as an event of its own - without a map
|
||||
* it has no attendees, and its occurrence goes to nobody.
|
||||
*/
|
||||
function withParticipantsOf(entry: OverrideEntry, participants: CalendarEvent['participants'] | undefined): OverrideEntry {
|
||||
const result = Object.fromEntries(Object.entries(entry).filter(([key]) => !key.startsWith('participants/')));
|
||||
if (!('participants' in result)) return result;
|
||||
if (participants && Object.keys(participants).length > 0) result.participants = participants;
|
||||
else delete result.participants;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** `date`, a display date in the calendar's zone, as the wall clock of `timeZone` to the minute, in milliseconds. */
|
||||
function zonedMinutesMs(date: Date, timeZone: string): number {
|
||||
const wall = wallClockMs(formatWallClock(fromDisplayDate(date), 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 calendar'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 (seriesClockDelta); the length only when the editor
|
||||
* changed it. Nothing when the occurrence did not move.
|
||||
*/
|
||||
function seriesWhen(
|
||||
occurrence: CalendarEvent,
|
||||
master: CalendarEvent,
|
||||
updates: Partial<CalendarEvent>,
|
||||
when: EditorTimes,
|
||||
): SeriesWhen {
|
||||
if (!editorTimesChanged(when, occurrence)) return {};
|
||||
const result: SeriesWhen = {};
|
||||
const allDayChanged = !!master.showWithoutTime !== when.allDay;
|
||||
const from = getEventStartDate(occurrence);
|
||||
|
||||
if (when.allDay) {
|
||||
// The series' first day as the calendar shows it, moved by the days the
|
||||
// occurrence moved.
|
||||
const first = !master.showWithoutTime && master.timeZone
|
||||
? moveLocalDateTime(master.start, master.timeZone, getEffectiveTimeZone())
|
||||
: master.start;
|
||||
const days = differenceInCalendarDays(when.start, from);
|
||||
if (days !== 0 || allDayChanged) {
|
||||
result.start = `${shiftLocalDateTime(`${first.slice(0, 10)}T00:00:00`, days * 86_400_000).slice(0, 10)}T00:00:00`;
|
||||
}
|
||||
} else {
|
||||
const delta = seriesClockDelta(from, when.start, master);
|
||||
if (delta !== 0 || allDayChanged) result.start = shiftLocalDateTime(master.start, delta);
|
||||
}
|
||||
|
||||
const seconds = parseDurationSeconds(occurrence.duration ?? master.duration);
|
||||
if (allDayChanged || parseDurationSeconds(updates.duration) !== seconds) {
|
||||
result.duration = updates.duration;
|
||||
}
|
||||
if (allDayChanged) {
|
||||
result.showWithoutTime = when.allDay;
|
||||
result.timeZone = updates.timeZone ?? null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The editor's fields a save changes on its own: when (seriesWhen), who (seriesParticipantFields), 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<SeriesEdit, 'ruleChanged'>,
|
||||
): Partial<CalendarEvent> {
|
||||
const ruleChanged = editor ? !!editor.ruleChanged : !recurrenceRulesEqual(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 the app 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]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The series' recurrence overrides once `series` - its change - applies,
|
||||
* or undefined when they stay as they are.
|
||||
*
|
||||
* An override is keyed by its occurrence's recurrence id, where the rule
|
||||
* puts the occurrence. A series that moved kept them under the old ids: an
|
||||
* excluded occurrence came back, a changed one showed twice (as changed at
|
||||
* the old time, and as the series has it), in the attendees' copies too
|
||||
* (live on 1.0 and 0.16.23). Now each override goes with its occurrence
|
||||
* (overrideKeyIn), and an override of an occurrence the series no longer
|
||||
* has is dropped. An occurrence moved on its own keeps its time, as the
|
||||
* user placed it - but the one the editor was opened on takes the move the
|
||||
* user made there - while an override that only holds the time the
|
||||
* 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; changes: Partial<CalendarEvent> },
|
||||
): OverrideMap | null | undefined {
|
||||
const overrides = master.recurrenceOverrides;
|
||||
if (!overrides || Object.keys(overrides).length === 0) return undefined;
|
||||
const resized = series.duration !== 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 = {
|
||||
start: series.start ?? master.start,
|
||||
showWithoutTime: series.showWithoutTime ?? !!master.showWithoutTime,
|
||||
recurrenceRules: options.ruleChanged ? series.recurrenceRules ?? null : master.recurrenceRules,
|
||||
};
|
||||
const turned = from.showWithoutTime !== to.showWithoutTime;
|
||||
const opened = resolveOverrideKey({ start: occurrence.start, recurrenceId: occurrence.recurrenceId ?? master.start }, overrides);
|
||||
|
||||
const result: OverrideMap = {};
|
||||
for (const [key, override] of Object.entries(overrides)) {
|
||||
const target = overrideKeyIn(key, from, to);
|
||||
if (!target) continue;
|
||||
const { updated: _updated, ...rest } = override as OverrideEntry;
|
||||
let entry: OverrideEntry = rest;
|
||||
if (!entry.excluded) {
|
||||
let follows = true;
|
||||
if (turned) {
|
||||
for (const timing of WHEN_KEYS) delete entry[timing];
|
||||
} else {
|
||||
if (typeof entry.start === 'string') {
|
||||
const pinned = wallClockMs(entry.start) === wallClockMs(key)
|
||||
&& (entry.timeZone === undefined || entry.timeZone === master.timeZone);
|
||||
follows = pinned || key === opened;
|
||||
if (follows) entry.start = movedLike(entry.start, from.start, to.start);
|
||||
}
|
||||
if (resized && typeof entry.duration === 'string'
|
||||
&& (key === opened || parseDurationSeconds(entry.duration) === parseDurationSeconds(master.duration))) {
|
||||
entry.duration = series.duration;
|
||||
}
|
||||
}
|
||||
if (options.askAgain && follows) entry = withAnswersAskedAgain(entry, master);
|
||||
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`.
|
||||
*
|
||||
* The editor shows one occurrence, so the dates and participants it writes
|
||||
* are that occurrence's. Written to the series as they are, the series
|
||||
* 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
|
||||
* or removed; when the series moves, the user as its organizer asks the
|
||||
* attendees again (seriesParticipantFields);
|
||||
* - its overrides, each with its occurrence (seriesOverrides).
|
||||
*/
|
||||
export function seriesUpdatesFromOccurrence(
|
||||
occurrence: CalendarEvent,
|
||||
master: CalendarEvent,
|
||||
updates: Partial<CalendarEvent>,
|
||||
editor: SeriesEdit,
|
||||
context: Omit<ParticipantSaveContext, 'rescheduled'>,
|
||||
): Partial<CalendarEvent> {
|
||||
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;
|
||||
const participants = seriesParticipantFields(master, occurrence, editor.attendees, { ...context, rescheduled });
|
||||
Object.assign(series, participants);
|
||||
const overrides = seriesOverrides(occurrence, master, series, {
|
||||
ruleChanged: !!editor.ruleChanged,
|
||||
askAgain: rescheduled && !!participants.participants,
|
||||
changes,
|
||||
});
|
||||
if (overrides !== undefined) series.recurrenceOverrides = overrides;
|
||||
return series;
|
||||
}
|
||||
|
||||
/** The calendar store's scheduling switch for one write: true sends the attendees messages, false is quiet. */
|
||||
interface SchedulingOptions {
|
||||
sendSchedulingMessages?: boolean;
|
||||
}
|
||||
|
||||
/** What saving an occurrence edit works with: the calendar store's actions. */
|
||||
export interface OccurrenceEditActions {
|
||||
updateEvent: (
|
||||
id: string,
|
||||
changes: Partial<CalendarEvent> | Record<string, unknown>,
|
||||
options?: SchedulingOptions,
|
||||
) => Promise<void>;
|
||||
/** The created event; a store that answers a failure with nothing counts as failed. */
|
||||
createEvent: (
|
||||
event: Partial<CalendarEvent>,
|
||||
calendarId: string,
|
||||
options?: SchedulingOptions,
|
||||
) => Promise<CalendarEvent | null | undefined>;
|
||||
/** The base event of a series, from one of its occurrences. */
|
||||
getMasterEvent: (occurrence: CalendarEvent) => Promise<CalendarEvent | null>;
|
||||
}
|
||||
|
||||
export interface OccurrenceEdit {
|
||||
/** The occurrence the editor was opened on. */
|
||||
event: CalendarEvent;
|
||||
/** What the editor wrote for it. */
|
||||
updates: Partial<CalendarEvent>;
|
||||
/** What the editor had. Without it the updates go to the series as they are. */
|
||||
editor?: SeriesEdit;
|
||||
/** The calendar (store id) the editor saves to: where the rest of a split series goes. */
|
||||
calendarId?: string;
|
||||
/** The editor's "Send invitations": the organizer's messages to the attendees. */
|
||||
sendScheduling?: boolean;
|
||||
/** The user's calendar addresses; the first organizes new invitations. */
|
||||
userEmails: string[];
|
||||
/** A name for an address the event data carries none for. */
|
||||
resolveName?: (email: string) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* An occurrence the server keeps as an event of its own - an invitation to
|
||||
* that occurrence alone - with no series to find: whatever the scope, it is
|
||||
* the one thing there is to change or delete.
|
||||
*/
|
||||
function isLoneOccurrence(event: CalendarEvent): boolean {
|
||||
return !!event.recurrenceId && !event.recurrenceRules?.length && !isServerRecurrenceInstance(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* The series `event` belongs to, or null. The store hands back the stored
|
||||
* event for an id it cannot place as a series; only one with a rule is one.
|
||||
*/
|
||||
async function seriesOf(event: CalendarEvent, actions: Pick<OccurrenceEditActions, 'getMasterEvent'>): Promise<CalendarEvent | null> {
|
||||
const master = await actions.getMasterEvent(event);
|
||||
return master && (master.recurrenceRules?.length ?? 0) > 0 ? master : null;
|
||||
}
|
||||
|
||||
/** The recurrence id `occurrence` has in `master`, the key of its override there. */
|
||||
function splitKeyOf(occurrence: CalendarEvent, master: CalendarEvent): string {
|
||||
return resolveOverrideKey(occurrence, master.recurrenceOverrides) ?? occurrence.recurrenceId ?? occurrence.start;
|
||||
}
|
||||
|
||||
/**
|
||||
* End the series `master` just before `occurrence`. With `sendScheduling`,
|
||||
* when the user organizes the series, the attendees are told: Stalwart
|
||||
* sends them the shortened series, and their copies end there too (they
|
||||
* kept every occurrence before); anyone else ends their own copy quietly.
|
||||
* The overrides from `occurrence` on go as well, in the same update: a rule
|
||||
* that ends does not end them, and each kept its occurrence - a changed one
|
||||
* showed on in the old series, next to the new one (live on 1.0 and
|
||||
* 0.16.23). Resolves to what puts the series back, for a rollback - its
|
||||
* rules as they were and those overrides - whether the attendees were told,
|
||||
* and the overrides, for the new series.
|
||||
*/
|
||||
async function truncateSeries(
|
||||
master: CalendarEvent,
|
||||
occurrence: CalendarEvent,
|
||||
actions: Pick<OccurrenceEditActions, 'updateEvent'>,
|
||||
scheduling: { sendScheduling?: boolean; userEmails: string[] },
|
||||
): Promise<{ restore: Partial<CalendarEvent>; announced: boolean; removed: OverrideMap }> {
|
||||
const originalRules: RecurrenceRule[] | null = master.recurrenceRules
|
||||
? JSON.parse(JSON.stringify(master.recurrenceRules))
|
||||
: null;
|
||||
const splitKey = splitKeyOf(occurrence, master);
|
||||
const until = untilBefore(splitKey);
|
||||
const truncatedRules = (master.recurrenceRules || []).map((rule) => ({
|
||||
...rule,
|
||||
until,
|
||||
count: null,
|
||||
}));
|
||||
const patch: Record<string, unknown> = { recurrenceRules: truncatedRules };
|
||||
const restore: Partial<CalendarEvent> = { recurrenceRules: originalRules };
|
||||
const removed: OverrideMap = {};
|
||||
const split = wallClockMs(splitKey);
|
||||
for (const [key, override] of Object.entries(master.recurrenceOverrides ?? {})) {
|
||||
const at = wallClockMs(key);
|
||||
if (at === null || split === null || at < split) continue;
|
||||
removed[key] = override;
|
||||
patch[`recurrenceOverrides/${key}`] = null;
|
||||
}
|
||||
// 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);
|
||||
await actions.updateEvent(master.id, patch, { sendSchedulingMessages: announced });
|
||||
return { restore, announced, removed };
|
||||
}
|
||||
|
||||
/** The UNTIL that ends a series just before its occurrence at `splitKey`: a second before it, on the series' own clock. */
|
||||
function untilBefore(splitKey: string): string {
|
||||
return shiftLocalDateTime(splitKey, -1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rules of the series "this and the following occurrences" becomes:
|
||||
* the old ones, each with what is left of its COUNT - the occurrences
|
||||
* before `splitKey` stay with the old series. Copied as they were, the new
|
||||
* series ran the whole count again from the split. An UNTIL stays.
|
||||
*/
|
||||
function remainingRules(
|
||||
rules: CalendarEvent['recurrenceRules'],
|
||||
seriesStart: string,
|
||||
splitKey: string,
|
||||
): CalendarEvent['recurrenceRules'] {
|
||||
if (!rules) return rules;
|
||||
return rules.map((rule) => (rule.count
|
||||
? { ...rule, count: Math.max(1, rule.count - occurrencesBefore(seriesStart, rule, splitKey)) }
|
||||
: rule));
|
||||
}
|
||||
|
||||
/**
|
||||
* The overrides of the new series "this and the following occurrences"
|
||||
* 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']; duration?: string },
|
||||
series: CalendarEvent,
|
||||
changes: Partial<CalendarEvent>,
|
||||
newSeries: Partial<CalendarEvent>,
|
||||
): OverrideMap | 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)) {
|
||||
const target = overrideKeyIn(key, from, to, splitKey);
|
||||
if (!target) continue;
|
||||
const opened = key === splitKey;
|
||||
const { updated: _updated, ...rest } = override as OverrideEntry;
|
||||
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 (!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 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 calendar'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,
|
||||
master: CalendarEvent,
|
||||
updates: Partial<CalendarEvent>,
|
||||
originalRules: CalendarEvent['recurrenceRules'],
|
||||
options: { editor?: SeriesEdit; 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 = editor
|
||||
? !!editor.ruleChanged
|
||||
: updates.recurrenceRules !== undefined && !recurrenceRulesEqual(updates.recurrenceRules, originalRules);
|
||||
const changes = editor ? changedEditorFields(occurrence, updates, editor) : updates;
|
||||
const { recurrenceRules: editorRules, ...changed } = changes;
|
||||
|
||||
const data: Partial<CalendarEvent> = {};
|
||||
const source = master as unknown as Record<string, unknown>;
|
||||
for (const key of FOLLOWING_SERIES_KEYS) {
|
||||
if (source[key] !== undefined) (data as Record<string, unknown>)[key] = source[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;
|
||||
}
|
||||
const raw = data as Record<string, unknown>;
|
||||
delete raw.id;
|
||||
delete raw.uid;
|
||||
delete raw.recurrenceId;
|
||||
delete raw.recurrenceOverrides;
|
||||
if (data.participants) {
|
||||
data.participants = participantsAskedAgain({
|
||||
participants: data.participants,
|
||||
organizerCalendarAddress: data.organizerCalendarAddress ?? master.organizerCalendarAddress,
|
||||
}) ?? data.participants;
|
||||
}
|
||||
const overrides = followingOverrides(
|
||||
options.overrides, splitKey,
|
||||
{ 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 ?? undefined, duration: data.duration,
|
||||
},
|
||||
master, editor ? changed : {}, data,
|
||||
);
|
||||
if (overrides) data.recurrenceOverrides = overrides;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the editor's change to one occurrence for `scope`. Resolves to false
|
||||
* when the series the occurrence belongs to cannot be found.
|
||||
*/
|
||||
export async function saveOccurrenceEdit(
|
||||
scope: RecurrenceEditScope,
|
||||
edit: OccurrenceEdit,
|
||||
actions: OccurrenceEditActions,
|
||||
): Promise<boolean> {
|
||||
const { updateEvent, createEvent } = actions;
|
||||
const { event, updates, sendScheduling } = edit;
|
||||
const scheduling = { sendSchedulingMessages: sendScheduling };
|
||||
if (scope === 'this' || isLoneOccurrence(event)) {
|
||||
// The store keeps the change on this occurrence: through its own
|
||||
// (synthetic) id, or as a recurrence override on the event it was
|
||||
// expanded from.
|
||||
await updateEvent(event.id, updates, scheduling);
|
||||
return true;
|
||||
}
|
||||
const master = await seriesOf(event, actions);
|
||||
if (!master) return false;
|
||||
if (scope === 'this_and_future') {
|
||||
const { restore, announced, removed } = await truncateSeries(master, event, actions, {
|
||||
sendScheduling, userEmails: edit.userEmails,
|
||||
});
|
||||
const newEventData = followingSeriesData(event, master, updates, restore.recurrenceRules ?? null, {
|
||||
editor: edit.editor,
|
||||
overrides: removed,
|
||||
});
|
||||
// The calendar is the one the editor saves to; the store routes it.
|
||||
delete newEventData.calendarIds;
|
||||
const calendarId = edit.calendarId || getPrimaryCalendarId(master) || '';
|
||||
try {
|
||||
const created = await createEvent(newEventData, calendarId, scheduling);
|
||||
if (!created) throw new Error('Failed to create the rest of the series');
|
||||
} catch (createError) {
|
||||
// Put the series back as it was, telling whoever the truncation told.
|
||||
try {
|
||||
await updateEvent(master.id, restore, { sendSchedulingMessages: announced });
|
||||
} catch {
|
||||
// The rollback failing is reported through the original error.
|
||||
}
|
||||
throw createError;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let seriesUpdates: Partial<CalendarEvent>;
|
||||
if (edit.editor) {
|
||||
seriesUpdates = seriesUpdatesFromOccurrence(event, master, updates, edit.editor, {
|
||||
userEmail: edit.userEmails[0],
|
||||
userEmails: edit.userEmails,
|
||||
resolveName: edit.resolveName,
|
||||
});
|
||||
} else {
|
||||
seriesUpdates = { ...updates };
|
||||
delete seriesUpdates.recurrenceId;
|
||||
}
|
||||
await updateEvent(master.id, seriesUpdates, scheduling);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** What deleting part of a series works with: the calendar store's actions. */
|
||||
export interface OccurrenceDeleteActions extends Pick<OccurrenceEditActions, 'updateEvent' | 'getMasterEvent'> {
|
||||
deleteEvent: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the part of a series `scope` names, from `event`, the occurrence
|
||||
* the user picked it on. Resolves to false when the series cannot be found.
|
||||
* The store tells the attendees of what it destroys itself; ending a series
|
||||
* tells them when the user organizes it.
|
||||
*/
|
||||
export async function deleteOccurrence(
|
||||
scope: RecurrenceEditScope,
|
||||
deletion: { event: CalendarEvent; userEmails: string[] },
|
||||
actions: OccurrenceDeleteActions,
|
||||
): Promise<boolean> {
|
||||
const { event } = deletion;
|
||||
if (scope === 'this' || isLoneOccurrence(event)) {
|
||||
// The store destroys a server occurrence, or excludes one the device
|
||||
// expanded on its base event.
|
||||
await actions.deleteEvent(event.id);
|
||||
return true;
|
||||
}
|
||||
const master = await seriesOf(event, actions);
|
||||
if (!master) return false;
|
||||
if (scope === 'this_and_future') {
|
||||
await truncateSeries(master, event, actions, { sendScheduling: true, userEmails: deletion.userEmails });
|
||||
return true;
|
||||
}
|
||||
await actions.deleteEvent(master.id);
|
||||
return true;
|
||||
}
|
||||
@@ -91,13 +91,8 @@ import {
|
||||
type TimeFormat,
|
||||
} from '../lib/calendar-utils';
|
||||
import { buildReplyTo } from '../lib/calendar-invitation';
|
||||
import {
|
||||
buildAllScopeEdit,
|
||||
buildFutureSeriesEdit,
|
||||
isRecurringSeriesMember,
|
||||
truncateRecurrenceRules,
|
||||
type SeriesEdit,
|
||||
} from '../lib/recurrence-overrides';
|
||||
import { isRecurringSeriesMember } from '../lib/recurrence-overrides';
|
||||
import { deleteOccurrence, saveOccurrenceEdit, type SeriesEdit } from '../lib/recurrence-scope-edit';
|
||||
import { participantLimitMessage } from '../lib/calendar-participants';
|
||||
import { maxParticipantsPerEvent } from '../api/calendar';
|
||||
import { useContactNameResolver } from '../lib/contact-name-resolver';
|
||||
@@ -110,7 +105,7 @@ import { startCalendarNotificationSync } from '../lib/calendar-notifications';
|
||||
import { useCalendarReminderOpen } from '../lib/calendar-reminder-open';
|
||||
import { shareEventICS } from '../lib/calendar-ics-export';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types';
|
||||
import type { Calendar, CalendarEvent } from '../api/types';
|
||||
|
||||
type ViewMode = 'month' | 'week' | 'day' | 'agenda';
|
||||
type PendingAction =
|
||||
@@ -603,24 +598,6 @@ export default function CalendarScreen() {
|
||||
}
|
||||
}, [deleteEvent, isReadOnlyEvent, reportError]);
|
||||
|
||||
// "This and following": end the master at the occurrence and hand back the
|
||||
// master plus its untouched rules so the caller can start a new series (or
|
||||
// roll back). Port of webmail's truncateRecurrenceAtEvent.
|
||||
const truncateRecurrenceAtEvent = React.useCallback(
|
||||
async (event: CalendarEvent) => {
|
||||
const master = await getMasterEvent(event);
|
||||
if (!master) return null;
|
||||
const originalRules = master.recurrenceRules
|
||||
? (JSON.parse(JSON.stringify(master.recurrenceRules)) as RecurrenceRule[])
|
||||
: null;
|
||||
await updateEvent(master.id, {
|
||||
recurrenceRules: truncateRecurrenceRules(master.recurrenceRules, event),
|
||||
});
|
||||
return { master, originalRules };
|
||||
},
|
||||
[getMasterEvent, updateEvent],
|
||||
);
|
||||
|
||||
// Send an answer and show it in the open detail sheet. 'occurrence'
|
||||
// answers just that occurrence of a series.
|
||||
const submitRsvp = React.useCallback(
|
||||
@@ -664,74 +641,22 @@ export default function CalendarScreen() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (action.kind === 'edit') {
|
||||
const { updates } = action;
|
||||
const opts = { sendSchedulingMessages: action.sendScheduling };
|
||||
const participantContext = {
|
||||
userEmail: currentUserEmails[0],
|
||||
// lib/recurrence-scope-edit: which occurrences change, and how the
|
||||
// series, its overrides and its attendees follow.
|
||||
const done = action.kind === 'edit'
|
||||
? await saveOccurrenceEdit(scope, {
|
||||
event,
|
||||
updates: action.updates,
|
||||
editor: action.edit,
|
||||
calendarId: action.calendarId,
|
||||
sendScheduling: action.sendScheduling,
|
||||
userEmails: currentUserEmails,
|
||||
resolveName: resolveContactName,
|
||||
};
|
||||
switch (scope) {
|
||||
case 'this': {
|
||||
// The store keeps the change on this occurrence: through its
|
||||
// own (synthetic) id, or as a recurrence override on the event
|
||||
// it was expanded from.
|
||||
await updateEvent(event.id, updates, opts);
|
||||
break;
|
||||
}
|
||||
case 'this_and_future': {
|
||||
const result = await truncateRecurrenceAtEvent(event);
|
||||
if (!result) throw new Error('Master event not found');
|
||||
const { master, originalRules } = result;
|
||||
const newEventData = buildFutureSeriesEdit(master, originalRules, event, updates, currentUserEmails);
|
||||
delete newEventData.calendarIds;
|
||||
const targetCalendarId =
|
||||
action.calendarId || getPrimaryCalendarId(master) || '';
|
||||
try {
|
||||
await createEvent(newEventData, targetCalendarId, opts);
|
||||
} catch (createError) {
|
||||
// Roll back the truncation so the series isn't left cut short.
|
||||
try {
|
||||
await updateEvent(master.id, { recurrenceRules: originalRules ?? [] });
|
||||
} catch {
|
||||
// The rollback failing is reported through the original error.
|
||||
}
|
||||
throw createError;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'all': {
|
||||
const master = await getMasterEvent(event);
|
||||
if (!master) throw new Error('Master event not found');
|
||||
const changes = action.edit
|
||||
? buildAllScopeEdit(updates, event, master, action.edit, participantContext)
|
||||
: updates;
|
||||
await updateEvent(master.id, changes, opts);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch (scope) {
|
||||
case 'this': {
|
||||
// The store destroys a server occurrence, or excludes one the
|
||||
// device expanded on its base event.
|
||||
await deleteEvent(event.id);
|
||||
break;
|
||||
}
|
||||
case 'this_and_future': {
|
||||
const result = await truncateRecurrenceAtEvent(event);
|
||||
if (!result) throw new Error('Master event not found');
|
||||
break;
|
||||
}
|
||||
case 'all': {
|
||||
const master = await getMasterEvent(event);
|
||||
if (!master) throw new Error('Master event not found');
|
||||
await deleteEvent(master.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { updateEvent, createEvent, getMasterEvent })
|
||||
: await deleteOccurrence(scope, { event, userEmails: currentUserEmails }, {
|
||||
updateEvent, deleteEvent, getMasterEvent,
|
||||
});
|
||||
if (!done) throw new Error('Master event not found');
|
||||
} catch (err) {
|
||||
Alert.alert(
|
||||
t('calendar.notifications.event_error', 'Something went wrong'),
|
||||
@@ -745,7 +670,7 @@ export default function CalendarScreen() {
|
||||
}
|
||||
},
|
||||
[
|
||||
pendingAction, updateEvent, deleteEvent, createEvent, getMasterEvent, truncateRecurrenceAtEvent, refresh,
|
||||
pendingAction, updateEvent, deleteEvent, createEvent, getMasterEvent, refresh,
|
||||
submitRsvp, t, currentUserEmails, resolveContactName,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// The scope edits (lib/recurrence-scope-edit) on the real calendar store,
|
||||
// with the JMAP calls mocked: what reaches CalendarEvent/set when "this and
|
||||
// the following occurrences" cannot create the rest of the series.
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.TZ = 'Europe/Berlin';
|
||||
});
|
||||
|
||||
vi.mock('../../api/calendar', () => ({
|
||||
getCalendars: vi.fn(),
|
||||
queryEvents: vi.fn(async () => []),
|
||||
getEvents: vi.fn(async () => []),
|
||||
getEventIdentities: vi.fn(async () => []),
|
||||
scanCalendarObjects: vi.fn(async () => []),
|
||||
isCalendarAccessDenied: vi.fn(() => false),
|
||||
noteCalendarAccessError: vi.fn(() => false),
|
||||
resetCalendarAccessDenied: vi.fn(),
|
||||
createEvent: vi.fn(),
|
||||
updateEvent: vi.fn(async () => undefined),
|
||||
deleteEvents: vi.fn(async () => undefined),
|
||||
batchCreateEvents: vi.fn(),
|
||||
setDefaultCalendar: vi.fn(),
|
||||
rsvpEvent: vi.fn(),
|
||||
supportsSyntheticCalendarIds: vi.fn(async () => false),
|
||||
queryExpandedEvents: vi.fn(async () => []),
|
||||
hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events),
|
||||
resetSyntheticIdSupport: vi.fn(),
|
||||
resetTimeZoneChecks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: { accountId: 'acc-1', isConnected: true },
|
||||
}));
|
||||
|
||||
import * as calendarApi from '../../api/calendar';
|
||||
import type { CalendarEvent } from '../../api/types';
|
||||
import { useCalendarStore } from '../calendar-store';
|
||||
import { saveOccurrenceEdit } from '../../lib/recurrence-scope-edit';
|
||||
import { editorTimesFromEvent } from '../../lib/event-editor-times';
|
||||
|
||||
const mockUpdate = vi.mocked(calendarApi.updateEvent);
|
||||
const mockCreate = vi.mocked(calendarApi.createEvent);
|
||||
const mockGetEvents = vi.mocked(calendarApi.getEvents);
|
||||
|
||||
const ORGANIZER = 'userc@example.org';
|
||||
const participants = {
|
||||
org: { calendarAddress: `mailto:${ORGANIZER}`, roles: { owner: true }, participationStatus: 'accepted' },
|
||||
b: { calendarAddress: 'mailto:userb@example.org', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||
};
|
||||
|
||||
// Five Mondays from 5 October, 10:00 in Berlin; userb declined 19 October
|
||||
// alone - on 1.0 an override holding just that answer, as a PatchObject.
|
||||
const master = {
|
||||
id: 'b', uid: 'weekly@example.org', title: 'Planning', start: '2026-10-05T10:00:00', duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin', calendarIds: { 'cal-1': true }, organizerCalendarAddress: `mailto:${ORGANIZER}`,
|
||||
participants, isOrigin: true,
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 }],
|
||||
recurrenceOverrides: { '2026-10-19T10:00:00': { 'participants/b/participationStatus': 'declined' } },
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
const occurrence = {
|
||||
...master, id: 's19', baseEventId: 'b', start: '2026-10-19T10:00:00', recurrenceId: '2026-10-19T10:00:00',
|
||||
utcStart: '2026-10-19T08:00:00Z', utcEnd: '2026-10-19T09:00:00Z',
|
||||
} as CalendarEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useCalendarStore.getState().reset();
|
||||
useCalendarStore.setState({ calendars: [{ id: 'cal-1', name: 'Work' }] as never, events: [occurrence], loadedRange: null });
|
||||
mockGetEvents.mockImplementation(async (ids: string[]) => (ids[0] === 'b' ? [master] : []));
|
||||
});
|
||||
|
||||
describe('this and the following occurrences on the calendar store (Stalwart 1.0)', () => {
|
||||
it('puts the series back as it was when the create fails, and fails the save', async () => {
|
||||
mockCreate.mockRejectedValue(new Error('Network request failed'));
|
||||
const store = useCalendarStore.getState();
|
||||
|
||||
await expect(saveOccurrenceEdit('this_and_future', {
|
||||
event: occurrence,
|
||||
updates: { title: 'Planning, room 2', start: occurrence.start, duration: 'PT1H', timeZone: 'Europe/Berlin', participants },
|
||||
editor: { attendees: [{ name: '', email: 'userb@example.org' }], when: editorTimesFromEvent(occurrence) },
|
||||
calendarId: 'cal-1', sendScheduling: true, userEmails: [ORGANIZER],
|
||||
}, { updateEvent: store.updateEvent, createEvent: store.createEvent, getMasterEvent: store.getMasterEvent }))
|
||||
.rejects.toThrow('Network request failed');
|
||||
|
||||
expect(mockUpdate.mock.calls.map(([id, patch, schedule]) => [id, patch, schedule])).toEqual([
|
||||
['b', {
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-10-19T09:59:59' }],
|
||||
'recurrenceOverrides/2026-10-19T10:00:00': null,
|
||||
}, true],
|
||||
// Its rule and the whole overrides map: a pointer fails on a series
|
||||
// the truncation left without overrides.
|
||||
['b', { recurrenceRules: master.recurrenceRules, recurrenceOverrides: master.recurrenceOverrides }, true],
|
||||
]);
|
||||
const [created, calendarId, schedule] = mockCreate.mock.calls[0];
|
||||
expect([calendarId, schedule]).toEqual(['cal-1', true]);
|
||||
expect(created).toMatchObject({ start: '2026-10-19T10:00:00', recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 3 }] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user