mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
fix: keep single edits off a series opened as itself and end series west of UTC
More edits of a recurring meeting went wrong at the edges of a series (0.16 and 1.0 alike, attendees included): - A series the app shows as itself - opened from a reminder or an event link, or one an edit just made recurring (0.16 before 0.16.20 showed it until the next reload) - took "This event only" as a change of the whole series: `CalendarEvent/set` on the series' id with the editor's title, start and rule. "Delete this event only" deleted the series. The series now stands for its first occurrence: a change becomes an override of it, in two writes when the user organizes the meeting, sends invitations and an attendee has answered (Stalwart sends a new override only to attendees who have not), and a delete excludes it. "This and following" from the first occurrence changes, or deletes, the whole series instead of ending it before its first occurrence. A series opened from a reminder on a calendar shared with the user is now known to the store, so the change goes to the owner's account under the series' own id. - An edit that makes an event recurring, or single again, now loads the range again, so the grid shows the occurrences instead of the series itself. - 0.16 (0.16.23 checked) reads a rule's UNTIL as the series' wall clock taken for UTC, so in a zone behind UTC a split or "delete this and following" kept the occurrence it was to end before. The UNTIL is now earlier by the zone's offset (the larger of the split's day and the day before), which ends the series there on 0.16 and 1.0 alike. - 0.16.6 to 0.16.23 send a change of one occurrence with its SEQUENCE raised but do not keep the raise, so a later change of every occurrence - a new title - went out with a lower number for that occurrence and the attendee's server refused it. A change of every occurrence that the user as organizer sends, of something the attendees see, on a series with changed occurrences, now raises the SEQUENCE of the series and of each changed occurrence by one. 1.0 raises and keeps the number itself; the result there is the same. The app's update sends `sequence` as it is, so nothing has to keep it. - An existing override written on the base event (0.16 before 0.16.20, and ranges the device expands) gets the series' organizer again when it has none: 0.16.19 refuses an answer to such an occurrence. 1.0 refuses the key inside an override once, and the store writes the override again without it.
This commit is contained in:
@@ -480,3 +480,34 @@ describe('hydrateRecurrenceInstances with a reply stub', () => {
|
||||
expect(hydrateRecurrenceInstances([{ ...stub(), title: '' }], withOverride({ title: '' }))[0].title).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// Until a562ea5 every editor save wrote the organizer into the override it
|
||||
// rewrote; 0.16.19 refuses an answer to an occurrence whose override names
|
||||
// none. An override on the base event (0.16 before 0.16.20, and ranges the
|
||||
// device expands) gets the series' organizer back when it has none; through
|
||||
// a synthetic id it is never sent. 1.0 refuses it inside an override once,
|
||||
// and the store writes the override again without it.
|
||||
describe.each(['0.16.19', '1.0'])('the organizer of an existing override written on the base event (Stalwart %s)', () => {
|
||||
const occurrence = (entry: Record<string, unknown>) => ({
|
||||
id: 'b:2026-09-08T10:00:00', originalId: 'b', start: '2026-09-08T10:00:00', recurrenceId: '2026-09-08T10:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' }],
|
||||
organizerCalendarAddress: 'mailto:org@example.com',
|
||||
recurrenceOverrides: { '2026-09-08T10:00:00': entry },
|
||||
}) as unknown as CalendarEvent;
|
||||
|
||||
it('adds it to an override that has none', () => {
|
||||
expect(withNewOverrideDetails(occurrence({ title: 'Moved' }), { title: 'Renamed' }, { seriesKeys: true }))
|
||||
.toEqual({ title: 'Renamed', organizerCalendarAddress: 'mailto:org@example.com' });
|
||||
});
|
||||
|
||||
it('leaves an override that has one, and a patch that sets one, alone', () => {
|
||||
const named = occurrence({ title: 'Moved', organizerCalendarAddress: 'mailto:org@example.com' });
|
||||
expect(withNewOverrideDetails(named, { title: 'Renamed' }, { seriesKeys: true })).toEqual({ title: 'Renamed' });
|
||||
expect(withNewOverrideDetails(occurrence({}), { organizerCalendarAddress: null } as never, { seriesKeys: true }))
|
||||
.toEqual({ organizerCalendarAddress: null });
|
||||
});
|
||||
|
||||
it('sends nothing of the series through a synthetic id', () => {
|
||||
expect(withNewOverrideDetails(occurrence({ title: 'Moved' }), { title: 'Renamed' })).toEqual({ title: 'Renamed' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -720,7 +720,7 @@ describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('where "thi
|
||||
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 deps = actions(series('1.0'));
|
||||
|
||||
const saved = await saveOccurrenceEdit('all', {
|
||||
event: occurrence,
|
||||
@@ -733,8 +733,11 @@ describe('saveOccurrenceEdit for every occurrence', () => {
|
||||
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 });
|
||||
// was stays the series' own. The attendees are told, and 19 October has
|
||||
// an override: the SEQUENCE goes up (see below).
|
||||
expect(deps.updateEvent).toHaveBeenCalledWith('b', {
|
||||
title: 'Planning, room 2', sequence: 1, 'recurrenceOverrides/2026-10-19T10:00:00/sequence': 1,
|
||||
}, { sendSchedulingMessages: true });
|
||||
});
|
||||
|
||||
it('reports a series it cannot find', async () => {
|
||||
@@ -976,3 +979,263 @@ describe('one occurrence the server keeps as its own event', () => {
|
||||
expect(deps.createEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// 0.16 sends the change of one occurrence with its SEQUENCE raised, without
|
||||
// keeping the raise itself. A later rename of every occurrence carried the
|
||||
// lower number for it, and the attendee's server refused the whole change:
|
||||
// the mail arrived, the copy stayed as it was (live on 0.16.23). 1.0 raises
|
||||
// every occurrence's SEQUENCE the same way itself for a change it sends.
|
||||
describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the SEQUENCE of a changed series on Stalwart %s', (_label, version) => {
|
||||
// Five Mondays from 5 October, 19 October with an override: on 0.16 it
|
||||
// holds a sequence of its own (copied when it got the override), on 1.0
|
||||
// the PatchObject leaves it out while it is the series'.
|
||||
const withSequences = (overrides: CalendarEvent['recurrenceOverrides'] = series(version).recurrenceOverrides) => ({
|
||||
...series(version), sequence: 2,
|
||||
recurrenceOverrides: version === '1.0'
|
||||
? overrides
|
||||
: Object.fromEntries(Object.entries(overrides ?? {}).map(([key, entry]) => [key, { ...entry, sequence: 2 }])),
|
||||
}) as CalendarEvent;
|
||||
const occurrenceOn26 = () => ({
|
||||
...serverOccurrence(version), id: 'maaaaad', start: '2026-10-26T10:00:00', recurrenceId: '2026-10-26T10:00:00',
|
||||
utcStart: '2026-10-26T09:00:00Z', utcEnd: '2026-10-26T10:00:00Z',
|
||||
}) as CalendarEvent;
|
||||
const save = async (
|
||||
master: CalendarEvent,
|
||||
change: Partial<CalendarEvent>,
|
||||
extra: { sendScheduling?: boolean } = {},
|
||||
editor: Partial<SeriesEdit> = {},
|
||||
) => {
|
||||
const occurrence = occurrenceOn26();
|
||||
const deps = actions(master);
|
||||
await saveOccurrenceEdit('all', {
|
||||
event: occurrence, updates: editorUpdates(occurrence, change), editor: editorOn(occurrence, editor),
|
||||
sendScheduling: true, userEmails: [ORGANIZER], ...extra,
|
||||
}, deps);
|
||||
return deps.updateEvent.mock.calls[0];
|
||||
};
|
||||
|
||||
it('raises the series\' and each changed occurrence\'s by one on a new title', async () => {
|
||||
const [id, patch, options] = await save(withSequences(), { title: 'Planning, room 2' });
|
||||
|
||||
expect([id, options]).toEqual(['b', { sendSchedulingMessages: true }]);
|
||||
expect(patch).toEqual({ title: 'Planning, room 2', sequence: 3, 'recurrenceOverrides/2026-10-19T10:00:00/sequence': 3 });
|
||||
});
|
||||
|
||||
it('raises them inside the overrides when the series moves', async () => {
|
||||
const { start, end } = editorTimesFromEvent(occurrenceOn26());
|
||||
const retitled = withSequences({ '2026-10-19T10:00:00': { title: 'Planning, special' } } as never);
|
||||
const [, patch] = await save(retitled, {}, {}, { when: { allDay: false, start: addHours(start, 1), end: addHours(end, 1) } });
|
||||
|
||||
const raised = patch as Record<string, unknown> & { recurrenceOverrides: Record<string, { sequence?: number }> };
|
||||
expect(raised.sequence).toBe(3);
|
||||
expect(raised.recurrenceOverrides['2026-10-19T11:00:00'].sequence).toBe(3);
|
||||
expect(Object.keys(raised).some((key) => key.startsWith('recurrenceOverrides/'))).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves them alone when nothing goes out, nothing the attendees see changes, or no occurrence has an override', async () => {
|
||||
const quiet = await save(withSequences(), { title: 'Planning, room 2' }, { sendScheduling: false });
|
||||
const alerts = await save(withSequences(), {
|
||||
alerts: { a1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } } as never,
|
||||
});
|
||||
const plain = await save({ ...withSequences(), recurrenceOverrides: null } as CalendarEvent, { title: 'Planning, room 2' });
|
||||
|
||||
for (const [, patch] of [quiet, alerts, plain]) {
|
||||
expect(patch).not.toHaveProperty('sequence');
|
||||
expect(Object.keys(patch).some((key) => key.endsWith('/sequence'))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves them alone on an attendee\'s own copy', async () => {
|
||||
const occurrence = occurrenceOn26();
|
||||
const deps = actions({ ...withSequences(), isOrigin: false });
|
||||
|
||||
await saveOccurrenceEdit('all', {
|
||||
event: occurrence, updates: editorUpdates(occurrence, { title: 'My name for it', participants: undefined }), editor: editorOn(occurrence),
|
||||
sendScheduling: false, userEmails: [ATTENDEE],
|
||||
}, deps);
|
||||
|
||||
expect(deps.updateEvent.mock.calls[0][1]).toEqual({ title: 'My name for it' });
|
||||
});
|
||||
});
|
||||
|
||||
// 0.16 (0.16.23 checked) reads UNTIL as the series' wall clock taken for
|
||||
// UTC: in New York "until 09:59:59" kept the 10:00 occurrence it was to end
|
||||
// before (live). Five hours earlier ends the series there on 0.16 and 1.0.
|
||||
describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('ending a series in a zone behind UTC on Stalwart %s', (_label, version) => {
|
||||
const mondays = (start: string, utc: string) => ({
|
||||
...series(version), start, timeZone: 'America/New_York', utcStart: utc, utcEnd: utc,
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], recurrenceOverrides: null,
|
||||
}) as unknown as CalendarEvent;
|
||||
const occurrenceOf = (master: CalendarEvent, recurrenceId: string, utc: string) => ({
|
||||
...master, id: 'maaaaac', baseEventId: 'b', start: recurrenceId, recurrenceId, recurrenceIdTimeZone: 'America/New_York',
|
||||
utcStart: utc, utcEnd: utc,
|
||||
}) as CalendarEvent;
|
||||
const untilOf = (patch: unknown) => (patch as { recurrenceRules: { until: string }[] }).recurrenceRules[0].until;
|
||||
|
||||
it('ends it early enough for 0.16', async () => {
|
||||
const master = mondays('2026-12-07T10:00:00', '2026-12-07T15:00:00Z');
|
||||
const deps = actions(master);
|
||||
|
||||
await deleteOccurrence('this_and_future', { event: occurrenceOf(master, '2026-12-21T10:00:00', '2026-12-21T15:00:00Z'), userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(deps.updateEvent.mock.calls[0][1]).toEqual({
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', until: '2026-12-21T04:59:59', count: null }],
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the larger offset when the clocks changed the day before', async () => {
|
||||
// Sunday 8 March 2026: New York went to summer time that night (UTC-5
|
||||
// on Saturday, UTC-4 from Sunday 03:00).
|
||||
const master = { ...mondays('2026-03-01T10:00:00', '2026-03-01T15:00:00Z'), recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' }] } as CalendarEvent;
|
||||
const deps = actions(master);
|
||||
|
||||
await deleteOccurrence('this_and_future', { event: occurrenceOf(master, '2026-03-08T10:00:00', '2026-03-08T14:00:00Z'), userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(untilOf(deps.updateEvent.mock.calls[0][1])).toBe('2026-03-08T04:59:59');
|
||||
});
|
||||
|
||||
it('ends a split the same way', async () => {
|
||||
const master = mondays('2026-12-07T10:00:00', '2026-12-07T15:00:00Z');
|
||||
const occurrence = occurrenceOf(master, '2026-12-21T10:00:00', '2026-12-21T15:00:00Z');
|
||||
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(untilOf(deps.updateEvent.mock.calls[0][1])).toBe('2026-12-21T04:59:59');
|
||||
});
|
||||
|
||||
it('ends a floating or all-day series a second before, as before', async () => {
|
||||
const floating = { ...mondays('2026-12-07T10:00:00', '2026-12-07T15:00:00Z'), timeZone: null } as CalendarEvent;
|
||||
const deps = actions(floating);
|
||||
|
||||
await deleteOccurrence('this_and_future', { event: occurrenceOf(floating, '2026-12-21T10:00:00', '2026-12-21T15:00:00Z'), userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(untilOf(deps.updateEvent.mock.calls[0][1])).toBe('2026-12-21T09:59:59');
|
||||
});
|
||||
});
|
||||
|
||||
// A series the app has not expanded - one opened from a reminder or a
|
||||
// link, one an edit just made recurring - shows as the series itself, once,
|
||||
// at its first occurrence. "This occurrence" of it went to the series: the
|
||||
// editor's data rewrote every occurrence, and deleting it deleted the series.
|
||||
describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the series itself standing for its first occurrence on Stalwart %s', (_label, version) => {
|
||||
// As CalendarEvent/get returns the base event.
|
||||
const base = (overrides: Partial<CalendarEvent> = {}) => ({
|
||||
...series(version), recurrenceOverrides: null, ...overrides,
|
||||
}) as CalendarEvent;
|
||||
const kickOff = (master: CalendarEvent) => editorUpdates(master, { title: 'Kick-off' });
|
||||
type Call = [string, Record<string, Record<string, Record<string, unknown>>>, unknown];
|
||||
|
||||
// userb accepted the series. Stalwart sends a new override only to
|
||||
// attendees who have not answered: the change went out in one write that
|
||||
// reached nobody who had. It now takes the store's two steps.
|
||||
it('changes only the first occurrence for "this occurrence", in two steps for those who answered', async () => {
|
||||
const master = base();
|
||||
const deps = actions(master);
|
||||
|
||||
const saved = await saveOccurrenceEdit('this', {
|
||||
event: master, updates: kickOff(master), sendScheduling: true, userEmails: [ORGANIZER],
|
||||
}, deps);
|
||||
|
||||
expect(saved).toBe(true);
|
||||
expect(deps.updateEvent).toHaveBeenCalledTimes(2);
|
||||
// First the occurrence as it is, without messages: the whole map, as the
|
||||
// series has no overrides yet; nothing of the series changes.
|
||||
const [id, copy, quiet] = deps.updateEvent.mock.calls[0] as unknown as Call;
|
||||
expect([id, quiet]).toEqual(['b', { sendSchedulingMessages: false }]);
|
||||
expect(Object.keys(copy)).toEqual(['recurrenceOverrides']);
|
||||
expect(copy.recurrenceOverrides['2026-10-05T10:00:00']).toMatchObject({ start: '2026-10-05T10:00:00', title: 'Planning' });
|
||||
// Then the change of that override, with messages.
|
||||
const [again, change, send] = deps.updateEvent.mock.calls[1] as unknown as Call;
|
||||
expect([again, send]).toEqual(['b', { sendSchedulingMessages: true }]);
|
||||
expect(Object.keys(change)).toEqual(['recurrenceOverrides/2026-10-05T10:00:00']);
|
||||
expect(change['recurrenceOverrides/2026-10-05T10:00:00']).toMatchObject({ start: '2026-10-05T10:00:00', title: 'Kick-off' });
|
||||
expect(statusOf(change['recurrenceOverrides/2026-10-05T10:00:00'].participants as CalendarEvent['participants'], ATTENDEE)).toBe('accepted');
|
||||
});
|
||||
|
||||
it('changes it in one write when nobody has answered, or nothing is sent', async () => {
|
||||
const unanswered = base({
|
||||
participants: { org: participant(version, ORGANIZER, true, 'accepted'), b: participant(version, ATTENDEE, false, 'needs-action') },
|
||||
});
|
||||
for (const [master, send] of [[unanswered, true], [base(), false]] as const) {
|
||||
const deps = actions(master);
|
||||
|
||||
await saveOccurrenceEdit('this', { event: master, updates: kickOff(master), sendScheduling: send, userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(deps.updateEvent).toHaveBeenCalledTimes(1);
|
||||
const [id, patch, options] = deps.updateEvent.mock.calls[0] as unknown as Call;
|
||||
expect([id, options]).toEqual(['b', { sendSchedulingMessages: send }]);
|
||||
expect(patch.recurrenceOverrides['2026-10-05T10:00:00']).toMatchObject({ title: 'Kick-off' });
|
||||
}
|
||||
});
|
||||
|
||||
it('adds the override to those the series has', async () => {
|
||||
const master = base({ recurrenceOverrides: series(version).recurrenceOverrides });
|
||||
const deps = actions(master);
|
||||
|
||||
await saveOccurrenceEdit('this', { event: master, updates: kickOff(master), sendScheduling: true, userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(Object.keys(deps.updateEvent.mock.calls[0][1])).toEqual(['recurrenceOverrides/2026-10-05T10:00:00']);
|
||||
});
|
||||
|
||||
it('changes the whole series for "this and following" from its first occurrence', async () => {
|
||||
const master = base();
|
||||
const deps = actions(master);
|
||||
|
||||
await saveOccurrenceEdit('this_and_future', {
|
||||
event: master, updates: kickOff(master), editor: editorOn(master), sendScheduling: true, userEmails: [ORGANIZER],
|
||||
}, deps);
|
||||
|
||||
// Not ended before its first occurrence, no new series: the series itself changes.
|
||||
expect(deps.createEvent).not.toHaveBeenCalled();
|
||||
expect(deps.updateEvent.mock.calls).toEqual([['b', { title: 'Kick-off' }, { sendSchedulingMessages: true }]]);
|
||||
});
|
||||
|
||||
it('changes or deletes the whole series for "this and following" from the first occurrence of an expanded series too', async () => {
|
||||
const master = base();
|
||||
const first = { ...master, id: 'maaaaab', baseEventId: 'b', recurrenceId: '2026-10-05T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin' } as CalendarEvent;
|
||||
const deps = actions(master);
|
||||
|
||||
await saveOccurrenceEdit('this_and_future', {
|
||||
event: first, updates: kickOff(first), editor: editorOn(first), sendScheduling: true, userEmails: [ORGANIZER],
|
||||
}, deps);
|
||||
await deleteOccurrence('this_and_future', { event: first, userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(deps.createEvent).not.toHaveBeenCalled();
|
||||
expect(deps.updateEvent.mock.calls).toEqual([['b', { title: 'Kick-off' }, { sendSchedulingMessages: true }]]);
|
||||
expect(deps.deleteEvent.mock.calls).toEqual([['b']]);
|
||||
});
|
||||
|
||||
it('excludes the first occurrence for "delete this occurrence"', async () => {
|
||||
const master = base();
|
||||
const deps = actions(master);
|
||||
|
||||
const deleted = await deleteOccurrence('this', { event: master, userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(deps.deleteEvent).not.toHaveBeenCalled();
|
||||
expect(deps.updateEvent.mock.calls).toEqual([['b', { recurrenceOverrides: { '2026-10-05T10:00:00': { excluded: true } } }]]);
|
||||
});
|
||||
|
||||
it('deletes the whole series for "delete this and following" from its first occurrence', async () => {
|
||||
const master = base();
|
||||
const deps = actions(master);
|
||||
|
||||
await deleteOccurrence('this_and_future', { event: master, userEmails: [ORGANIZER] }, deps);
|
||||
|
||||
expect(deps.deleteEvent).toHaveBeenCalledWith('b');
|
||||
expect(deps.updateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses rather than touch a series it cannot find', async () => {
|
||||
const deps = actions(null);
|
||||
|
||||
expect(await saveOccurrenceEdit('this', { event: base(), updates: { title: 'x' }, userEmails: [ORGANIZER] }, deps)).toBe(false);
|
||||
expect(await deleteOccurrence('this', { event: base(), userEmails: [ORGANIZER] }, deps)).toBe(false);
|
||||
expect(deps.updateEvent).not.toHaveBeenCalled();
|
||||
expect(deps.deleteEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -542,6 +542,18 @@ export function isBrowserExpandedOccurrence(
|
||||
type OccurrenceOverrideContext = Pick<CalendarEvent, 'id' | 'start' | 'recurrenceId' | 'recurrenceOverrides'>
|
||||
& Partial<Pick<CalendarEvent, 'originalId' | 'baseEventId'>>;
|
||||
|
||||
/**
|
||||
* `occurrence` as seen from `master`, the base event it belongs to, for
|
||||
* building an override patch on the master: the master's overrides map is
|
||||
* the one that counts.
|
||||
*/
|
||||
export function overrideContextOf(
|
||||
occurrence: CalendarEvent,
|
||||
master: Pick<CalendarEvent, 'recurrenceOverrides'>,
|
||||
): CalendarEvent {
|
||||
return { ...occurrence, baseEventId: undefined, recurrenceOverrides: master.recurrenceOverrides ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The base event's overrides map as far as `occurrence` knows it, or
|
||||
* `undefined` when it does not. The device's expansion copies the base
|
||||
@@ -654,18 +666,37 @@ export interface OverridePatchOptions {
|
||||
seriesKeys?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `patch` for an occurrence's existing override on the base event, with the
|
||||
* series' organizer when that override names none - as every editor save
|
||||
* wrote it before 2a3febc: 0.16.19 refuses an answer to one occurrence whose
|
||||
* override has no organizer (see buildOccurrenceRsvpPatch).
|
||||
* 0.16.23 takes the key and drops it; Stalwart 1.0 refuses it inside an
|
||||
* override, and the store then writes the override once more without series
|
||||
* keys. Through a synthetic id it is never sent.
|
||||
*/
|
||||
function withOverrideOrganizer(occurrence: Partial<CalendarEvent>, patch: Partial<CalendarEvent>): Partial<CalendarEvent> {
|
||||
if (!occurrence.organizerCalendarAddress || 'organizerCalendarAddress' in patch || !occurrence.recurrenceId) return patch;
|
||||
const overrides = knownOverrides(occurrence);
|
||||
const key = resolveOverrideKey({ start: occurrence.start ?? '', recurrenceId: occurrence.recurrenceId }, overrides);
|
||||
const entry = key ? overrides?.[key] : undefined;
|
||||
if (!entry || 'organizerCalendarAddress' in entry) return patch;
|
||||
return { ...patch, organizerCalendarAddress: occurrence.organizerCalendarAddress };
|
||||
}
|
||||
|
||||
/**
|
||||
* `patch` plus the occurrence details a new override needs
|
||||
* (NEW_OVERRIDE_COPIED_KEYS). Nothing is added when the occurrence already
|
||||
* has an override - an attendee may not add most of these to the organizer's
|
||||
* - or when that is unknown.
|
||||
* - or when that is unknown; an existing override written on the base event
|
||||
* gets the series' organizer if it has none (withOverrideOrganizer).
|
||||
*/
|
||||
export function withNewOverrideDetails(
|
||||
occurrence: Partial<CalendarEvent>,
|
||||
patch: Partial<CalendarEvent>,
|
||||
options: OverridePatchOptions = {},
|
||||
): Partial<CalendarEvent> {
|
||||
if (!createsOverride(occurrence)) return patch;
|
||||
if (!createsOverride(occurrence)) return options.seriesKeys ? withOverrideOrganizer(occurrence, patch) : patch;
|
||||
const names: readonly string[] = options.seriesKeys
|
||||
? [...NEW_OVERRIDE_COPIED_KEYS, ...NEW_OVERRIDE_SERIES_KEYS]
|
||||
: NEW_OVERRIDE_COPIED_KEYS;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { CalendarEvent, RecurrenceRule } from '../api/types';
|
||||
import type { RecurrenceEditScope } from '../components/calendar/RecurrenceScopeDialog';
|
||||
import { getEventStartDate, getPrimaryCalendarId } from './calendar-utils';
|
||||
import {
|
||||
hasAnsweredAttendees,
|
||||
participantsAskedAgain,
|
||||
seriesParticipantFields,
|
||||
userOrganizes,
|
||||
@@ -19,11 +20,18 @@ import {
|
||||
} from './calendar-participants';
|
||||
import { editorTimesChanged, type EditorTimes } from './event-editor-times';
|
||||
import {
|
||||
buildFallbackExcludePatch,
|
||||
buildFallbackOverridePatch,
|
||||
buildOccurrencePatch,
|
||||
buildOverrideInTwoSteps,
|
||||
createsOverride,
|
||||
isBrowserExpandedOccurrence,
|
||||
isServerRecurrenceInstance,
|
||||
overrideContextOf,
|
||||
parseDurationSeconds,
|
||||
resolveOverrideKey,
|
||||
stableJson,
|
||||
withNewOverrideDetails,
|
||||
} from './recurrence-instances';
|
||||
import { occurrencesBefore, seriesOccursAt } from './recurrence-expansion';
|
||||
import { recurrenceRulesEqual } from './recurrence';
|
||||
@@ -31,6 +39,7 @@ import {
|
||||
formatWallClock,
|
||||
fromDisplayDate,
|
||||
getEffectiveTimeZone,
|
||||
getTimeZoneOffsetMs,
|
||||
isValidTimeZone,
|
||||
localDateTimeToInstant,
|
||||
} from './calendar-timezone';
|
||||
@@ -437,6 +446,61 @@ export function seriesUpdatesFromOccurrence(
|
||||
return series;
|
||||
}
|
||||
|
||||
/** Changes of a series its attendees see - Stalwart announces them, and 1.0 raises the SEQUENCE for them. */
|
||||
const ANNOUNCED_KEYS = [
|
||||
'title', 'description', 'locations', 'virtualLocations', 'status', 'participants', 'recurrenceRules',
|
||||
'excludedRecurrenceRules', 'recurrenceOverrides', ...WHEN_KEYS,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* `series`, a change to the series `master` its attendees are told of, with
|
||||
* the SEQUENCE of the series and of each of its changed occurrences one up.
|
||||
*
|
||||
* 0.16 (0.16.6 to 0.16.23) sends the change of one occurrence with that
|
||||
* occurrence's SEQUENCE raised, but does not keep the raise itself (in
|
||||
* organizer.rs the list of components to raise is shadowed for such
|
||||
* changes). A later change of every occurrence that does not raise the
|
||||
* number again - a new title - then carried a lower SEQUENCE for that
|
||||
* occurrence than the attendee's copy has, and the attendee's server
|
||||
* refused the whole change as out of sequence: the mail arrived, the copy
|
||||
* stayed as it was (live on 0.16.23). One up from what the organizer's
|
||||
* copy holds is at least what the attendees got. 1.0 raises every
|
||||
* occurrence's SEQUENCE the same way itself for a change it announces, and
|
||||
* keeps a number it would have given (live: the same result with and
|
||||
* without). The app's update sends `sequence` as it is; nothing strips it.
|
||||
*/
|
||||
function withRaisedSequences(series: Partial<CalendarEvent>, master: CalendarEvent): Record<string, unknown> {
|
||||
const base = master.sequence ?? 0;
|
||||
const raised: Record<string, unknown> = { ...series, sequence: base + 1 };
|
||||
const raise = (entry: OverrideEntry) => (entry.excluded ? entry : { ...entry, sequence: Number(entry.sequence ?? base) + 1 });
|
||||
if (series.recurrenceOverrides !== undefined) {
|
||||
if (series.recurrenceOverrides) {
|
||||
raised.recurrenceOverrides = Object.fromEntries(Object.entries(series.recurrenceOverrides)
|
||||
.map(([key, entry]) => [key, raise(entry as OverrideEntry)]));
|
||||
}
|
||||
} else {
|
||||
for (const [key, entry] of Object.entries(master.recurrenceOverrides ?? {})) {
|
||||
if (!(entry as OverrideEntry).excluded) raised[`recurrenceOverrides/${key}/sequence`] = raise(entry as OverrideEntry).sequence;
|
||||
}
|
||||
}
|
||||
return raised;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether saving `series` for every occurrence of `master` raises their
|
||||
* SEQUENCE (withRaisedSequences): the user organizes the series and tells
|
||||
* its attendees of a change they see, and it has occurrences of its own a
|
||||
* change of one of them may have left behind.
|
||||
*/
|
||||
function raisesSequences(series: Partial<CalendarEvent>, master: CalendarEvent, edit: OccurrenceEdit): boolean {
|
||||
return !!edit.sendScheduling
|
||||
&& !!edit.editor
|
||||
&& Object.keys(master.participants ?? {}).length > 0
|
||||
&& userOrganizes(master, edit.userEmails)
|
||||
&& Object.values(master.recurrenceOverrides ?? {}).some((entry) => !(entry as OverrideEntry).excluded)
|
||||
&& ANNOUNCED_KEYS.some((key) => key in series);
|
||||
}
|
||||
|
||||
/** The calendar store's scheduling switch for one write: true sends the attendees messages, false is quiet. */
|
||||
interface SchedulingOptions {
|
||||
sendSchedulingMessages?: boolean;
|
||||
@@ -494,6 +558,58 @@ async function seriesOf(event: CalendarEvent, actions: Pick<OccurrenceEditAction
|
||||
return master && (master.recurrenceRules?.length ?? 0) > 0 ? master : null;
|
||||
}
|
||||
|
||||
/** A series itself, not one of its occurrences: it has rules and no recurrence id. */
|
||||
function isSeries(event: CalendarEvent): boolean {
|
||||
return (event.recurrenceRules?.length ?? 0) > 0 && !event.recurrenceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* `event` as an occurrence of `master`, to override or exclude on it: itself
|
||||
* when it is one, the series' first occurrence when it is the series itself.
|
||||
* The app shows a series it has not expanded - one opened from a reminder or
|
||||
* a link, one an edit just made recurring - as the series, once, at its
|
||||
* first occurrence; a change to "this occurrence" of it went to the series
|
||||
* and rewrote every occurrence, and deleting it deleted them all.
|
||||
*/
|
||||
function asOccurrenceOf(event: CalendarEvent, master: CalendarEvent): CalendarEvent {
|
||||
const occurrence = event.recurrenceId
|
||||
? event
|
||||
: { ...event, recurrenceId: master.start, recurrenceIdTimeZone: master.timeZone ?? null };
|
||||
return overrideContextOf(occurrence, master);
|
||||
}
|
||||
|
||||
/** Whether `event` is `master`'s first occurrence, or the series itself standing for it. */
|
||||
function isFirstOccurrence(event: CalendarEvent, master: CalendarEvent): boolean {
|
||||
if (!event.recurrenceId) return true;
|
||||
return wallClockMs(event.recurrenceId) === wallClockMs(master.start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change `occurrence` of `master`, which has no override yet, so that the
|
||||
* change reaches every attendee who has not declined - in the two steps the
|
||||
* calendar store takes for an occurrence of an expanded series
|
||||
* (updateOccurrenceInTwoSteps). Stalwart sends a new override only to
|
||||
* attendees who have not answered, a change to an existing one to all who
|
||||
* have not declined: the occurrence first gets its override as it is,
|
||||
* without messages, and the change then goes out as a change of it. This is
|
||||
* the series itself standing for its first occurrence, which changed in one
|
||||
* write that reached nobody who had answered. The override is written on
|
||||
* the series, under the key it keeps, so the second step needs no fresh
|
||||
* read.
|
||||
*/
|
||||
async function overrideForEveryone(
|
||||
occurrence: CalendarEvent,
|
||||
master: CalendarEvent,
|
||||
updates: Partial<CalendarEvent>,
|
||||
actions: Pick<OccurrenceEditActions, 'updateEvent'>,
|
||||
): Promise<boolean> {
|
||||
const steps = buildOverrideInTwoSteps(occurrence, updates);
|
||||
if (!steps) return false;
|
||||
await actions.updateEvent(master.id, steps.quiet, { sendSchedulingMessages: false });
|
||||
await actions.updateEvent(master.id, steps.change, { sendSchedulingMessages: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -521,7 +637,7 @@ async function truncateSeries(
|
||||
? JSON.parse(JSON.stringify(master.recurrenceRules))
|
||||
: null;
|
||||
const splitKey = splitKeyOf(occurrence, master);
|
||||
const until = untilBefore(splitKey);
|
||||
const until = untilBefore(splitKey, master);
|
||||
const truncatedRules = (master.recurrenceRules || []).map((rule) => ({
|
||||
...rule,
|
||||
until,
|
||||
@@ -545,9 +661,29 @@ async function truncateSeries(
|
||||
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 UNTIL that ends `series` just before its occurrence at `splitKey`: a
|
||||
* second before it, on the series' own clock - and earlier by as much as the
|
||||
* series' zone is behind UTC. 0.16 (0.16.23 checked) takes the UNTIL for
|
||||
* the series' wall clock, turns it into UTC and compares that with the
|
||||
* wall clock of each occurrence: in New York "until 09:59:59" kept the
|
||||
* 10:00 occurrence it was to end before, "until 04:59:59" ends before it.
|
||||
* 1.0 reads the UNTIL on the series' clock, and ends it there either way as
|
||||
* long as its occurrences are further apart than that - hours, for a daily
|
||||
* or weekly series never. The offset is the larger of the split's day and
|
||||
* the day before, for a change of daylight saving time between them.
|
||||
*/
|
||||
function untilBefore(splitKey: string, series: CalendarEvent): string {
|
||||
let back = 1000;
|
||||
const zone = !series.showWithoutTime && series.timeZone && isValidTimeZone(series.timeZone) ? series.timeZone : null;
|
||||
if (zone) {
|
||||
const instant = localDateTimeToInstant(splitKey.slice(0, 19), zone);
|
||||
if (instant) {
|
||||
const offsets = [instant, new Date(instant.getTime() - 86_400_000)].map((at) => getTimeZoneOffsetMs(at, zone));
|
||||
back += Math.max(0, -Math.min(...offsets));
|
||||
}
|
||||
}
|
||||
return shiftLocalDateTime(splitKey, -back);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -723,15 +859,45 @@ export async function saveOccurrenceEdit(
|
||||
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.
|
||||
if (scope === 'this') {
|
||||
if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(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;
|
||||
}
|
||||
// The series itself standing for its first occurrence: override that
|
||||
// occurrence on the series, never write it to the series itself.
|
||||
const master = await seriesOf(event, actions);
|
||||
if (master) {
|
||||
const occurrence = asOccurrenceOf(event, master);
|
||||
const participants = updates.participants === undefined ? occurrence.participants : updates.participants;
|
||||
if (sendScheduling && createsOverride(occurrence) && userOrganizes({ ...occurrence, participants }, edit.userEmails)
|
||||
&& hasAnsweredAttendees(participants)) {
|
||||
return overrideForEveryone(occurrence, master, updates, actions);
|
||||
}
|
||||
const overridePatch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true }));
|
||||
if (!overridePatch) return false;
|
||||
await updateEvent(master.id, overridePatch, scheduling);
|
||||
return true;
|
||||
}
|
||||
// An occurrence the server keeps as an event of its own (an invitation
|
||||
// to that occurrence alone) is itself what changes; a series that
|
||||
// cannot be found is not.
|
||||
if (isSeries(event)) return false;
|
||||
await updateEvent(event.id, updates, scheduling);
|
||||
return true;
|
||||
}
|
||||
if (isLoneOccurrence(event)) {
|
||||
await updateEvent(event.id, updates, scheduling);
|
||||
return true;
|
||||
}
|
||||
const master = await seriesOf(event, actions);
|
||||
if (!master) return false;
|
||||
// From the first occurrence on is the whole series; ended before its first
|
||||
// occurrence, the series would be left with none.
|
||||
if (scope === 'this_and_future' && isFirstOccurrence(event, master)) return saveOccurrenceEdit('all', edit, actions);
|
||||
if (scope === 'this_and_future') {
|
||||
const { restore, announced, removed } = await truncateSeries(master, event, actions, {
|
||||
sendScheduling, userEmails: edit.userEmails,
|
||||
@@ -768,7 +934,8 @@ export async function saveOccurrenceEdit(
|
||||
seriesUpdates = { ...updates };
|
||||
delete seriesUpdates.recurrenceId;
|
||||
}
|
||||
await updateEvent(master.id, seriesUpdates, scheduling);
|
||||
const write = raisesSequences(seriesUpdates, master, edit) ? withRaisedSequences(seriesUpdates, master) : seriesUpdates;
|
||||
await updateEvent(master.id, write, scheduling);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -789,15 +956,34 @@ export async function deleteOccurrence(
|
||||
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.
|
||||
if (scope === 'this') {
|
||||
if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) {
|
||||
// The store destroys a server occurrence, or excludes one the device
|
||||
// expanded on its base event.
|
||||
await actions.deleteEvent(event.id);
|
||||
return true;
|
||||
}
|
||||
// The series itself standing for its first occurrence: exclude that
|
||||
// occurrence on the series, never delete the series. The store tells
|
||||
// the attendees as it does for its own deletes.
|
||||
const master = await seriesOf(event, actions);
|
||||
if (master) {
|
||||
const excludePatch = buildFallbackExcludePatch(asOccurrenceOf(event, master));
|
||||
if (!excludePatch) return false;
|
||||
await actions.updateEvent(master.id, excludePatch);
|
||||
return true;
|
||||
}
|
||||
if (isSeries(event)) return false;
|
||||
await actions.deleteEvent(event.id);
|
||||
return true;
|
||||
}
|
||||
if (isLoneOccurrence(event)) {
|
||||
await actions.deleteEvent(event.id);
|
||||
return true;
|
||||
}
|
||||
const master = await seriesOf(event, actions);
|
||||
if (!master) return false;
|
||||
if (scope === 'this_and_future') {
|
||||
if (scope === 'this_and_future' && !isFirstOccurrence(event, master)) {
|
||||
await truncateSeries(master, event, actions, { sendScheduling: true, userEmails: deletion.userEmails });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -99,3 +99,96 @@ describe('this and the following occurrences on the calendar store (Stalwart 1.0
|
||||
expect(created).toMatchObject({ start: '2026-10-19T10:00:00', recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 3 }] });
|
||||
});
|
||||
});
|
||||
|
||||
// The series itself, opened from a reminder or a link: not in the grid, and
|
||||
// on a calendar shared with the user (the owner's account 'f'). "This
|
||||
// occurrence" of it wrote the editor's data to the whole series before; it
|
||||
// is an override of its first occurrence now, and has to reach the owner's
|
||||
// account under the raw id - the store knew nothing of the series.
|
||||
describe('the series itself opened from a reminder on the calendar store', () => {
|
||||
const shared = {
|
||||
...master, id: 'f:b', originalId: 'b', accountId: 'f', isShared: true, calendarIds: { 'f:cal': true },
|
||||
recurrenceOverrides: null,
|
||||
} as CalendarEvent;
|
||||
|
||||
it.each(['0.16.23', '1.0'])('overrides its first occurrence in the owner\'s account on Stalwart %s', async () => {
|
||||
useCalendarStore.setState({ events: [] });
|
||||
const store = useCalendarStore.getState();
|
||||
|
||||
await saveOccurrenceEdit('this', {
|
||||
event: shared,
|
||||
updates: { title: 'Kick-off', start: shared.start, duration: 'PT1H', timeZone: 'Europe/Berlin' },
|
||||
sendScheduling: false, userEmails: [ORGANIZER],
|
||||
}, { updateEvent: store.updateEvent, createEvent: store.createEvent, getMasterEvent: store.getMasterEvent });
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
const [id, patch, schedule, accountId] = mockUpdate.mock.calls[0];
|
||||
expect([id, schedule, accountId]).toEqual(['b', false, 'f']);
|
||||
expect(Object.keys(patch)).toEqual(['recurrenceOverrides']);
|
||||
expect((patch as { recurrenceOverrides: Record<string, unknown> }).recurrenceOverrides['2026-10-05T10:00:00'])
|
||||
.toMatchObject({ start: '2026-10-05T10:00:00', title: 'Kick-off' });
|
||||
});
|
||||
});
|
||||
|
||||
// A change that makes an event a series (or a single event again) changes
|
||||
// which occurrences the grid shows, and only a range load expands a series:
|
||||
// until then the grid had the series itself, once (0.16 before 0.16.20,
|
||||
// where nothing else reloads the range).
|
||||
describe('an event an edit makes recurring (Stalwart 0.16.19)', () => {
|
||||
it('loads the range again, and not for a single event that stays one', async () => {
|
||||
const single = (id: string) => ({
|
||||
id, uid: `${id}@example.org`, title: 'Once', start: '2026-10-05T10:00:00', duration: 'PT1H', calendarIds: { 'cal-1': true },
|
||||
}) as CalendarEvent;
|
||||
const refresh = vi.fn(async () => undefined);
|
||||
useCalendarStore.setState({ events: [single('x'), single('y')], refresh });
|
||||
|
||||
await useCalendarStore.getState().updateEvent('y', { title: 'Once, renamed' });
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
|
||||
await useCalendarStore.getState().updateEvent('x', { recurrenceRules: [{ frequency: 'weekly' }] });
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// The raised SEQUENCE of an "all" change (0.16.6 to 0.16.23 keep no raise of
|
||||
// a single occurrence's change) goes out as it is: the app's update strips
|
||||
// no `sequence`, so nothing has to keep it.
|
||||
describe.each(['0.16.23', '1.0'])('an announced change of every occurrence through the store on Stalwart %s', () => {
|
||||
it('sends the raised SEQUENCE of the series and of its changed occurrence', async () => {
|
||||
mockGetEvents.mockImplementation(async (ids: string[]) => (ids[0] === 'b' ? [{ ...master, sequence: 4 }] : []));
|
||||
const store = useCalendarStore.getState();
|
||||
|
||||
await saveOccurrenceEdit('all', {
|
||||
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) },
|
||||
sendScheduling: true, userEmails: [ORGANIZER],
|
||||
}, { updateEvent: store.updateEvent, createEvent: store.createEvent, getMasterEvent: store.getMasterEvent });
|
||||
|
||||
expect(mockUpdate.mock.calls.map(([id, patch, schedule]) => [id, patch, schedule])).toEqual([
|
||||
['b', { title: 'Planning, room 2', sequence: 5, 'recurrenceOverrides/2026-10-19T10:00:00/sequence': 5 }, true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// An override written on the base event by an older save, without the
|
||||
// organizer: 0.16.19 refuses an answer to such an occurrence, so a change of
|
||||
// it writes the series' organizer back (0.16 before 0.16.20: the device
|
||||
// expands the series).
|
||||
describe('a change of an occurrence whose override has no organizer (Stalwart 0.16.19)', () => {
|
||||
it('writes the series\' organizer into the override', async () => {
|
||||
const deviceOccurrence = {
|
||||
...master, id: 'b:2026-10-19T10:00:00', originalId: 'b', start: '2026-10-19T10:00:00', recurrenceId: '2026-10-19T10:00:00',
|
||||
recurrenceOverrides: { '2026-10-19T10:00:00': { title: 'Planning, special', start: '2026-10-19T10:00:00', duration: 'PT1H' } },
|
||||
} as CalendarEvent;
|
||||
useCalendarStore.setState({ events: [deviceOccurrence] });
|
||||
|
||||
await useCalendarStore.getState().updateEvent(deviceOccurrence.id, { title: 'Planning, room 2' }, { sendSchedulingMessages: true });
|
||||
|
||||
const [id, patch] = mockUpdate.mock.calls[0];
|
||||
expect(id).toBe('b');
|
||||
expect((patch as Record<string, Record<string, unknown>>)['recurrenceOverrides/2026-10-19T10:00:00']).toMatchObject({
|
||||
title: 'Planning, room 2', organizerCalendarAddress: `mailto:${ORGANIZER}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -954,7 +954,11 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
hasSchedulingParticipants(storeEvent)
|
||||
? true
|
||||
: undefined);
|
||||
// A change that makes an event a series, or a single event again, changes
|
||||
// which occurrences the grid shows: only a range load expands a series,
|
||||
// and until then the grid had the series itself, once.
|
||||
const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent))
|
||||
|| 'recurrenceRules' in patch
|
||||
|| hasServerOccurrencesOf(get().events, realId, accountId);
|
||||
// An occurrence's alerts that turn the calendar's defaults off.
|
||||
const stopsDefaultAlerts = patch.useDefaultAlerts === false && 'alerts' in patch;
|
||||
@@ -1041,6 +1045,10 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
|
||||
getMasterEvent: async (event) => {
|
||||
if (event.recurrenceRules?.length && !event.recurrenceId && !isServerRecurrenceInstance(event)) {
|
||||
// The series itself, opened from a reminder or a link rather than the
|
||||
// grid: known from here on, so a change to it is sent to its own
|
||||
// account under its raw id (resolveMutationTarget).
|
||||
if (!get().events.some((e) => e.id === event.id)) knownMasters.set(event.id, event);
|
||||
return event;
|
||||
}
|
||||
// Expansion replaces the master with its occurrences, each pointing back
|
||||
|
||||
Reference in New Issue
Block a user