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. Two edits left
them behind (live on 1.0 and 0.16.23, attendees included):

- Moving every occurrence kept the overrides under the old ids. A weekly
  series moved an hour got its cancelled 16 December back, at 11:00,
  and showed its retitled 30 December twice, at 10:00 and at 11:00;
  1.0 doubled it in the attendee's copy as well.
- "This and following events" ended the rule but not the overrides after
  the split, which then showed on as occurrences of the old series next
  to the new one, and the new series got the whole COUNT again: six
  weekly occurrences split at the third ran to eight dates, 4 January
  twice. Deleting "this and following" left the later overrides too.

Now:
- Moving the series moves each override with its occurrence, in the same
  update: by as much as the series' start moved, 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, which
  takes the move made there; an override that only held the time its
  occurrence had moves along, and so does a pinned length. When the rule
  changes, overrides of occurrences the new rule no longer has are
  dropped. When the attendees are asked again, so they are in the
  overrides that move.
- The split removes the overrides from the split on together with the
  rule change and gives them to the new series, re-keyed and with the new
  series' participants: 0.16 keeps an override as an event of its own,
  so one without them reached no attendee. The new series' COUNT is what
  is left of the old one; an UNTIL stays, and a rule the user changed in
  the editor is taken as it is. A failed create puts the overrides back
  with the rule.
- Delete "this and following" removes the overrides from there on.
This commit is contained in:
Linus Rath
2026-09-26 02:46:12 +02:00
parent 9f7c12ab35
commit e6b8c4ed50
4 changed files with 625 additions and 41 deletions
+40 -1
View File
@@ -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-alerts';
import type { CalendarEvent, CalendarRecurrenceRule } from '@/lib/jmap/types';
@@ -776,3 +776,42 @@ describe('expandRecurringEvents', () => {
});
});
});
// What the scope code asks 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', () => {
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 = [rule({ 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', [rule({ frequency: 'weekly' })], '2026-12-23T00:00:00')).toBe(true);
});
});
+256 -6
View File
@@ -276,6 +276,243 @@ describe('changing every occurrence of a series in another time zone', () => {
});
});
// 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 Bulwark 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',
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) => ({
when: { allDay: false, start: addHours(getEventStartDate(occurrence), hours), end: addHours(getEventEndDate(occurrence), 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', frequency: 'weekly', interval: 2, count: 5 }] as never;
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');
});
});
// "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(deps.client, 'b', {
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-12-21T09:59:59' }],
'recurrenceOverrides/2027-01-04T10:00:00': null,
}, 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][1].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', frequency: 'daily', count: 10 }] as never;
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][1].recurrenceRules).toEqual(daily);
});
// 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), deleteEvent: vi.fn().mockResolvedValue(undefined) };
await deleteOccurrence('this_and_future', { event: occurrenceOf(master, '2026-12-18T10:00:00'), sendScheduling: true, userEmails: [ORGANIZER] }, deps);
expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', {
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-12-18T09:59:59' }],
'recurrenceOverrides/2027-01-01T10:00:00': null,
}, true);
});
});
function actions(master: CalendarEvent | null) {
return {
client: {} as IJMAPClient,
@@ -330,6 +567,9 @@ describe('saveOccurrenceEdit for every occurrence', () => {
// every occurrence of it.
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();
@@ -342,11 +582,17 @@ describe.each(occurrences)('saving this and the following occurrences on Stalwar
expect(saved).toBe(true);
expect(deps.updateEvent).toHaveBeenCalledTimes(1);
expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { recurrenceRules: truncated }, true);
expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', truncation, true);
const [, created, send] = deps.createEvent.mock.calls[0];
expect(send).toBe(true);
expect(created).toMatchObject({ title: 'Planning, room 2', start: '2026-10-19T10:00:00', recurrenceRules: series(version).recurrenceRules });
for (const key of ['id', 'uid', 'recurrenceId']) expect(created).not.toHaveProperty(key);
// 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.
for (const key of ['id', 'uid', 'recurrenceId', 'recurrenceOverrides']) expect(created).not.toHaveProperty(key);
expect(statusOf(created.participants, ORGANIZER)).toBe('accepted');
expect(statusOf(created.participants, ATTENDEE)).toBe('needs-action');
});
@@ -360,7 +606,10 @@ describe.each(occurrences)('saving this and the following occurrences on Stalwar
event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: true, userEmails: [ORGANIZER],
}, deps)).rejects.toThrow('more than 20');
expect(deps.updateEvent).toHaveBeenLastCalledWith(deps.client, 'b', { recurrenceRules: series(version).recurrenceRules }, true);
expect(deps.updateEvent).toHaveBeenLastCalledWith(deps.client, 'b', {
recurrenceRules: series(version).recurrenceRules,
'recurrenceOverrides/2026-10-19T10:00:00': series(version).recurrenceOverrides!['2026-10-19T10:00:00'],
}, true);
});
it('ends an attendee\'s own copy quietly', async () => {
@@ -371,7 +620,7 @@ describe.each(occurrences)('saving this and the following occurrences on Stalwar
event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: true, userEmails: [ATTENDEE],
}, deps);
expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { recurrenceRules: truncated }, undefined);
expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', truncation, undefined);
});
// The series in userc's calendar, shared with usera read & write: the
@@ -386,7 +635,7 @@ describe.each(occurrences)('saving this and the following occurrences on Stalwar
sendScheduling: true, userEmails: [SHAREE],
}, deps);
expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', { recurrenceRules: truncated }, true);
expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', truncation, true);
const [, created, send] = deps.createEvent.mock.calls[0];
expect(send).toBe(true);
expect(statusOf(created.participants, ORGANIZER)).toBe('accepted');
@@ -545,6 +794,7 @@ describe.each(occurrences)('deleting part of a series from one occurrence on Sta
expect(deps.updateEvent).toHaveBeenCalledWith(deps.client, 'b', {
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-10-19T09:59:59' }],
'recurrenceOverrides/2026-10-19T10:00:00': null,
}, true);
expect(deps.deleteEvent).not.toHaveBeenCalled();
});
+36 -5
View File
@@ -71,6 +71,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: CalendarRecurrenceRule, 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: CalendarRecurrenceRule[] | 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();
}
@@ -403,6 +434,7 @@ function generateDates(
rangeStart: Date,
rangeEnd: Date,
includeStartDate = true,
maxOccurrences = 500,
): Date[] {
const rule = addImplicitByX(rawRule, eventStart);
const dates: Date[] = [];
@@ -418,11 +450,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) {
+293 -29
View File
@@ -23,8 +23,11 @@ import {
isBrowserExpandedOccurrence,
isServerRecurrenceInstance,
overrideContextOf,
resolveOverrideKey,
withNewOverrideDetails,
} from '@/lib/recurrence-instances';
import { occurrencesBefore, seriesOccursAt } from '@/lib/recurrence-expansion';
import { sameRecurrenceRules } from '@/lib/recurrence-rule';
import { getEffectiveTimeZone, moveLocalDateTime } from '@/lib/timezone';
import { debug } from '@/lib/debug';
@@ -76,15 +79,102 @@ 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 match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
if (!match) return value;
const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0));
const shifted = new Date(0);
shifted.setUTCFullYear(year, month - 1, day);
shifted.setUTCHours(hour, minute, second, 0);
return new Date(shifted.getTime() + ms).toISOString().slice(0, 19);
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);
}
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 (live on 0.16.23).
*/
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;
}
/** An override that changes nothing about an occurrence the series has (a PatchObject without a key). */
function isEmptyOverride(entry: OverrideEntry): boolean {
return Object.keys(entry).length === 0;
}
/**
@@ -161,6 +251,73 @@ function seriesParticipants(
return participantFieldsToSave(master, attendees, context);
}
/**
* 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.
*/
function seriesOverrides(
occurrence: CalendarEvent,
master: CalendarEvent,
series: Partial<CalendarEvent>,
options: { ruleChanged: boolean; askAgain: boolean },
): OverrideMap | null | undefined {
const overrides = master.recurrenceOverrides;
if (!overrides || Object.keys(overrides).length === 0) return undefined;
const resized = series.duration !== undefined;
if (series.start === undefined && series.showWithoutTime === undefined && !resized && !options.ruleChanged) return undefined;
const 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 (isEmptyOverride(entry) && seriesOccursAt(to.start, to.recurrenceRules, target)) continue;
}
result[target] = entry as OverrideMap[string];
}
return Object.keys(result).length > 0 ? result : null;
}
/**
* The editor's `updates`, made on `occurrence`, as a change to its whole
* series `master`.
@@ -174,7 +331,8 @@ function seriesParticipants(
* 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 (participantFieldsToSave).
* attendees again (participantFieldsToSave);
* - its overrides, each with its occurrence (seriesOverrides).
*/
export function seriesUpdatesFromOccurrence(
occurrence: CalendarEvent,
@@ -189,10 +347,14 @@ export function seriesUpdatesFromOccurrence(
}
const when = seriesWhen(occurrence, master, updates, editor.when);
Object.assign(series, when);
Object.assign(series, seriesParticipants(master, editor, {
...context,
rescheduled: Object.keys(when).length > 0 || !!editor.ruleChanged,
}));
const rescheduled = Object.keys(when).length > 0 || !!editor.ruleChanged;
const participants = seriesParticipants(master, editor, { ...context, rescheduled });
Object.assign(series, participants);
const overrides = seriesOverrides(occurrence, master, series, {
ruleChanged: !!editor.ruleChanged,
askAgain: rescheduled && !!participants.participants,
});
if (overrides !== undefined) series.recurrenceOverrides = overrides;
return series;
}
@@ -261,24 +423,33 @@ function isFirstOccurrence(event: CalendarEvent, master: CalendarEvent): boolean
return parseISO(event.recurrenceId).getTime() === parseISO(master.start).getTime();
}
/** 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). Resolves to the series' rules as they were
* and whether the attendees were told, for a rollback.
* kept every occurrence before). The overrides from `occurrence` on go as
* well: 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, 'client' | 'updateEvent'>,
scheduling: { sendScheduling?: boolean; userEmails?: string[] } = {},
): Promise<{ originalRules: CalendarEvent['recurrenceRules']; announced: true | undefined }> {
): Promise<{ restore: Partial<CalendarEvent>; announced: true | undefined; removed: OverrideMap }> {
const originalRules = master.recurrenceRules
? JSON.parse(JSON.stringify(master.recurrenceRules))
: null;
const occurrenceDate = occurrence.recurrenceId || occurrence.start;
const untilDate = new Date(occurrenceDate);
const splitKey = splitKeyOf(occurrence, master);
const untilDate = new Date(splitKey);
untilDate.setSeconds(untilDate.getSeconds() - 1);
const until = format(untilDate, "yyyy-MM-dd'T'HH:mm:ss");
const truncatedRules = (master.recurrenceRules || []).map(rule => ({
@@ -286,25 +457,106 @@ async function truncateSeries(
until,
count: null,
}));
const patch: Record<string, unknown> = { recurrenceRules: truncatedRules };
const restore: Record<string, unknown> = { 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;
restore[`recurrenceOverrides/${key}`] = override;
}
const announced = scheduling.sendScheduling && userOrganizes(master, scheduling.userEmails ?? []) ? true : undefined;
await actions.updateEvent(actions.client, master.id, { recurrenceRules: truncatedRules }, announced);
return { originalRules, announced };
await actions.updateEvent(actions.client, master.id, patch as Partial<CalendarEvent>, announced);
return { restore: restore as Partial<CalendarEvent>, announced, removed };
}
/**
* 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 after the split (truncateSeries), each
* with its occurrence (overrideKeyIn). They keep what the organizer changed
* - an exclusion, a title, a time of their own - but take the new series'
* participants (withParticipantsOf): it asks every attendee again, for each
* occurrence, and an attendee's answer to one occurrence of the old series
* (Stalwart keeps it as an override holding that attendee) would leave the
* others out of it. The occurrence the editor was opened on starts the new
* series, which is what the editor wrote for it: its override stays behind.
*/
function followingOverrides(
overrides: OverrideMap | undefined,
splitKey: string,
from: SeriesFrame,
to: SeriesFrame & { participants?: CalendarEvent['participants'] },
): OverrideMap | undefined {
if (!overrides || Object.keys(overrides).length === 0) return undefined;
const turned = from.showWithoutTime !== to.showWithoutTime;
const result: OverrideMap = {};
for (const [key, override] of Object.entries(overrides)) {
if (key === splitKey) continue;
const target = overrideKeyIn(key, from, to, splitKey);
if (!target) continue;
const { updated: _updated, ...rest } = override as OverrideEntry;
const 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 (entry.timeZone === undefined && (from.timeZone ?? null) !== (to.timeZone ?? null)) {
// A time of its own, on the old series' clock.
entry.timeZone = from.timeZone ?? null;
}
}
if (isEmptyOverride(entry) && seriesOccursAt(to.start, to.recurrenceRules, target)) continue;
}
result[target] = entry as OverrideMap[string];
}
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* The new series "this and the following occurrences" becomes: the old
* series' details with the editor's changes, from `occurrence` on. It is a
* new event with a UID of its own, so every attendee is asked
* (participantsAskedAgain): Stalwart invites to a new event only attendees
* who have not answered, and with the answers the old series had, those
* who had answered never got the new one.
* series' details with the editor's changes, from `occurrence` on, with
* what is left of its COUNT and the overrides it takes over (`overrides`,
* from truncateSeries). It is a new event with a UID of its own, so every
* attendee is asked (participantsAskedAgain): Stalwart invites to a new
* event only attendees who have not answered, and with the answers the old
* series had, those who had answered never got the new one.
*/
export function followingSeriesData(
occurrence: CalendarEvent,
master: CalendarEvent,
updates: Partial<CalendarEvent>,
originalRules: CalendarEvent['recurrenceRules'],
options: { editor?: EventEditorState; overrides?: OverrideMap } = {},
): Partial<CalendarEvent> {
const splitKey = splitKeyOf(occurrence, master);
// A rule the user changed in the editor is the new series' own, its COUNT
// included; one the user left alone continues the old series.
const ruleChanged = options.editor
? !!options.editor.ruleChanged
: updates.recurrenceRules !== undefined && !sameRecurrenceRules(updates.recurrenceRules, originalRules);
const { recurrenceRules: editorRules, ...changes } = updates;
const data: Partial<CalendarEvent> = {
title: master.title,
description: master.description,
@@ -315,8 +567,8 @@ export function followingSeriesData(
freeBusyStatus: master.freeBusyStatus,
privacy: master.privacy,
showWithoutTime: master.showWithoutTime,
recurrenceRules: originalRules,
...updates,
...changes,
recurrenceRules: ruleChanged && editorRules !== undefined ? editorRules : remainingRules(originalRules, master.start, splitKey),
start: updates.start || occurrence.recurrenceId || occurrence.start,
};
delete data.id;
@@ -328,6 +580,15 @@ export function followingSeriesData(
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,
},
);
if (overrides) data.recurrenceOverrides = overrides;
return data;
}
@@ -377,14 +638,17 @@ export async function saveOccurrenceEdit(
// From the first occurrence on is the whole series; ended before its
// first occurrence, the series would be left with none.
if (isFirstOccurrence(event, master)) return saveOccurrenceEdit('all', edit, actions);
const { originalRules, announced } = await truncateSeries(master, event, actions, { sendScheduling, userEmails: edit.userEmails });
const newEventData = followingSeriesData(event, master, updates, originalRules);
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,
});
try {
await createEvent(client, newEventData, sendScheduling);
} catch (createError) {
debug.error('Failed to create new series, rolling back master truncation:', createError);
try {
await updateEvent(client, master.id, { recurrenceRules: originalRules }, announced);
await updateEvent(client, master.id, restore, announced);
} catch (rollbackError) {
debug.error('Rollback of master event also failed:', rollbackError);
}