fix: keep occurrence changes on their days, and drop them when repeating stops

Two faults in how a series' changed and cancelled occurrences follow a
change of every occurrence, both from e6b8c4ed (live on 1.0 and
0.16.23, organizer and attendee copies):

- A rule that names its days - Mondays and Tuesdays, weekdays, the 5th
  of the month - keeps its occurrences on those days when the series'
  start moves. Moving every occurrence by a day moved each override
  along anyway, so the change of Monday 14 December landed on the key
  of Tuesday 15 December and one silently overwrote the other: the
  cancelled Tuesday came back with Monday's title, and Monday lost its
  change. Under such a rule every override but the first occurrence's
  now stays on its day, at the series' new time, or is dropped when the
  series no longer has that occurrence; the time 0.16 keeps in an
  override stays with it. The rest of a series split "this and
  following" keeps them the same way. Two overrides that still meet on
  one occurrence - the first occurrence's, moved onto the Tuesday after
  it when the series starts a day later - no longer overwrite each
  other: the one that stays where it is, or on its day, keeps the
  occurrence, and the other is left out and logged.
- "Repeat: none" for every occurrence kept the override of the first
  occurrence on the single event that was left, because a series'
  start counts as an occurrence of any rule. Cancelled, the event was
  gone from every range on 1.0 (still stored); retitled, 0.16.23 listed
  it twice. A series made single keeps no overrides, as before
  e6b8c4ed.
This commit is contained in:
Linus Rath
2026-09-26 04:43:45 +02:00
parent 36fc9f5497
commit 977a55f229
2 changed files with 250 additions and 13 deletions
+176
View File
@@ -249,6 +249,7 @@ describe.each(occurrences)('changing every occurrence from one of them on Stalwa
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', () => {
@@ -415,6 +416,181 @@ describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('overrides
});
});
// A rule that names the days - Mondays and Tuesdays - keeps its occurrences
// on them when the series starts a day later. Each override went a day
// later with the series: Monday 14 December's onto Tuesday 15 December, over
// that day's cancellation. The cancelled Tuesday came back with Monday's
// title, and Monday lost its change (live on 1.0 and 0.16.23).
describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('overrides of a series on named weekdays on Stalwart %s', (_label, version) => {
const special = (start: string, title = 'Standup, special') => (version === '1.0'
? { title }
: { updated: '2026-11-20T09:00:00Z', start, duration: 'PT1H', title, participants: series(version).participants });
// Mondays and Tuesdays (or the days given) from Monday 7 December, 10:00 in Berlin, ten times.
function onDays(overrides: Record<string, unknown>, days = ['mo', 'tu'], frequency = 'weekly'): CalendarEvent {
return {
...series(version), title: 'Standup', start: '2026-12-07T10:00:00', utcStart: '2026-12-07T09:00:00Z', utcEnd: '2026-12-07T10:00:00Z',
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency, byDay: days.map((day) => ({ '@type': 'NDay', day })), count: 10 }],
recurrenceOverrides: overrides,
} as unknown as CalendarEvent;
}
const on = (master: CalendarEvent, recurrenceId: string) => ({
...master, id: 'maaaaae', baseEventId: 'b', start: recurrenceId, recurrenceId, recurrenceIdTimeZone: 'Europe/Berlin',
utcStart: berlinUtc(recurrenceId), utcEnd: berlinUtc(recurrenceId, 1),
}) as CalendarEvent;
// Both orders: the server hands the map out in an order of its own.
const bothOrders = (entries: Array<[string, unknown]>) => [Object.fromEntries(entries), Object.fromEntries([...entries].reverse())];
// The overrides after the save: those written, or the series' own when none are.
const after = (master: CalendarEvent, updates: Partial<CalendarEvent>) =>
(updates.recurrenceOverrides === undefined ? master.recurrenceOverrides : updates.recurrenceOverrides) as Record<string, Record<string, unknown>>;
it('keeps Monday\'s change and Tuesday\'s cancellation on their days when the series starts a day later', () => {
for (const overrides of bothOrders([
['2026-12-14T10:00:00', special('2026-12-14T10:00:00', 'Standup, Monday special')],
['2026-12-15T10:00:00', { excluded: true }],
])) {
const master = onDays(overrides);
const monday = on(master, '2026-12-21T10:00:00');
const updates = seriesUpdatesFromOccurrence(monday, master, editorUpdates(monday), editorOn(monday, moveBy(monday, 24)), context);
expect(updates.start).toBe('2026-12-08T10:00:00');
const kept = after(master, updates);
expect(Object.keys(kept).sort()).toEqual(['2026-12-14T10:00:00', '2026-12-15T10:00:00']);
expect(kept['2026-12-15T10:00:00']).toEqual({ excluded: true });
expect(kept['2026-12-14T10:00:00']).toMatchObject({ title: 'Standup, Monday special' });
// 0.16: the time the override holds stays Monday's.
if (version === '0.16') expect(kept['2026-12-14T10:00:00'].start).toBe('2026-12-14T10:00:00');
// 1.0: nothing about them changes, so they are not written.
else expect(updates).not.toHaveProperty('recurrenceOverrides');
}
});
it('keeps the change of the Monday the editor was opened on on that Monday', () => {
const master = onDays({ '2026-12-21T10:00:00': special('2026-12-21T10:00:00') });
const monday = { ...on(master, '2026-12-21T10:00:00'), title: 'Standup, special' } as CalendarEvent;
const updates = seriesUpdatesFromOccurrence(monday, master, editorUpdates(monday), editorOn(monday, moveBy(monday, 24)), context);
const kept = after(master, updates);
expect(Object.keys(kept)).toEqual(['2026-12-21T10:00:00']);
expect(kept['2026-12-21T10:00:00']).toMatchObject(version === '1.0' ? { title: 'Standup, special' } : { title: 'Standup, special', start: '2026-12-21T10:00:00' });
});
it('keeps them on their days at the new time when the series also moves an hour', () => {
const master = onDays({ '2026-12-14T10:00:00': special('2026-12-14T10:00:00'), '2026-12-15T10:00:00': { excluded: true } });
const monday = on(master, '2026-12-21T10:00:00');
const updates = seriesUpdatesFromOccurrence(monday, master, editorUpdates(monday), editorOn(monday, moveBy(monday, 25)), context);
expect(updates.start).toBe('2026-12-08T11:00:00');
const overrides = updates.recurrenceOverrides as Record<string, Record<string, unknown>>;
expect(Object.keys(overrides).sort()).toEqual(['2026-12-14T11:00:00', '2026-12-15T11:00:00']);
expect(overrides['2026-12-15T11:00:00']).toEqual({ excluded: true });
expect(overrides['2026-12-14T11:00:00']).toMatchObject(version === '1.0' ? { title: 'Standup, special' } : { title: 'Standup, special', start: '2026-12-14T11:00:00' });
});
it('keeps Thursday\'s change and Friday\'s cancellation of a series on weekdays', () => {
const master = onDays({
'2026-12-10T10:00:00': special('2026-12-10T10:00:00', 'Standup, Thursday special'),
'2026-12-11T10:00:00': { excluded: true },
}, ['mo', 'tu', 'we', 'th', 'fr'], 'daily');
const tuesday = on(master, '2026-12-08T10:00:00');
const updates = seriesUpdatesFromOccurrence(tuesday, master, editorUpdates(tuesday), editorOn(tuesday, moveBy(tuesday, 24)), context);
expect(updates.start).toBe('2026-12-08T10:00:00');
const kept = after(master, updates);
expect(Object.keys(kept).sort()).toEqual(['2026-12-10T10:00:00', '2026-12-11T10:00:00']);
expect(kept['2026-12-10T10:00:00']).toMatchObject({ title: 'Standup, Thursday special' });
expect(kept['2026-12-11T10:00:00']).toEqual({ excluded: true });
});
// The series starts on Tuesday 8 December: its first Monday is gone, and
// moved a day, that Monday's override met Tuesday's cancellation.
it('drops the override of an occurrence that is gone rather than write it over the next one', () => {
for (const [hours, tuesday] of [[24, '2026-12-08T10:00:00'], [25, '2026-12-08T11:00:00']] as const) {
for (const overrides of bothOrders([
['2026-12-07T10:00:00', special('2026-12-07T10:00:00', 'Kick-off')],
['2026-12-08T10:00:00', { excluded: true }],
])) {
const master = onDays(overrides);
const monday = on(master, '2026-12-21T10:00:00');
const updates = seriesUpdatesFromOccurrence(monday, master, editorUpdates(monday), editorOn(monday, moveBy(monday, hours)), context);
expect(updates.recurrenceOverrides).toEqual({ [tuesday]: { excluded: true } });
}
}
});
it('keeps them on their days in the rest of a series split from a Monday moved to Tuesday', async () => {
const master = onDays({ '2026-12-28T10:00:00': special('2026-12-28T10:00:00'), '2026-12-29T10:00:00': { excluded: true } });
const monday = on(master, '2026-12-21T10:00:00');
const deps = actions(master);
await saveOccurrenceEdit('this_and_future', {
event: monday, updates: editorUpdates(monday), editor: editorOn(monday, moveBy(monday, 24)), sendScheduling: true, userEmails: [ORGANIZER],
}, deps);
const [, created] = deps.createEvent.mock.calls[0];
expect(created.start).toBe('2026-12-22T10:00:00');
const overrides = created.recurrenceOverrides as Record<string, Record<string, unknown>>;
expect(Object.keys(overrides).sort()).toEqual(['2026-12-28T10:00:00', '2026-12-29T10:00:00']);
expect(overrides['2026-12-29T10:00:00']).toEqual({ excluded: true });
expect(overrides['2026-12-28T10:00:00']).toMatchObject(version === '1.0' ? { title: 'Standup, special' } : { title: 'Standup, special', start: '2026-12-28T10:00:00' });
});
});
// "Repeat: none" for every occurrence leaves one event. The override of its
// first occurrence stayed with it: 1.0 applied it to the event - cancelled,
// the event was gone from every range - and 0.16.23 listed a retitled one
// next to the event (live).
describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('a series that stops repeating on Stalwart %s', (_label, version) => {
const firstChanged = (first: Record<string, unknown>) => ({
...series(version),
recurrenceOverrides: {
'2026-10-05T10:00:00': version === '1.0' || first.excluded
? first
: { start: '2026-10-05T10:00:00', duration: 'PT1H', participants: series(version).participants, ...first },
'2026-10-26T10:00:00': { excluded: true },
},
}) as unknown as CalendarEvent;
const none = { recurrenceRules: null, recurrenceOverrides: null } as Partial<CalendarEvent>;
it('keeps no override, whether its first occurrence was changed or cancelled', () => {
for (const first of [{ title: 'Kick-off' }, { excluded: true }]) {
const occurrence = serverOccurrence(version);
const updates = seriesUpdatesFromOccurrence(
occurrence, firstChanged(first), editorUpdates(occurrence, none), editorOn(occurrence, { ruleChanged: true }), context,
);
expect(updates.recurrenceRules).toBeNull();
expect(updates.recurrenceOverrides).toBeNull();
expect(updates).not.toHaveProperty('start');
}
});
it('drops them when the editor wrote only the rule', () => {
const occurrence = serverOccurrence(version);
const updates = seriesUpdatesFromOccurrence(
occurrence, firstChanged({ title: 'Kick-off' }), editorUpdates(occurrence, { recurrenceRules: null }),
editorOn(occurrence, { ruleChanged: true }), context,
);
expect(updates.recurrenceOverrides).toBeNull();
});
it('writes the event without them when it tells the attendees', async () => {
const occurrence = serverOccurrence(version);
const deps = actions(firstChanged({ title: 'Kick-off' }));
await saveOccurrenceEdit('all', {
event: occurrence, updates: editorUpdates(occurrence, none), editor: editorOn(occurrence, { ruleChanged: true }),
sendScheduling: true, userEmails: [ORGANIZER],
}, deps);
const [, id, patch] = deps.updateEvent.mock.calls[0];
expect(id).toBe('b');
expect(patch).toMatchObject({ recurrenceRules: null, recurrenceOverrides: null });
expect(Object.keys(patch).some((key) => key.startsWith('recurrenceOverrides/'))).toBe(false);
});
});
// "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
+74 -13
View File
@@ -124,6 +124,15 @@ interface SeriesFrame {
recurrenceRules: CalendarEvent['recurrenceRules'];
}
/**
* Whether `rules` name the days of their occurrences - BYDAY, BYMONTHDAY,
* BYYEARDAY; other clients write BYDAY even for a single weekday. A start
* moved by days does not take those occurrences along.
*/
function namesDays(rules: CalendarEvent['recurrenceRules']): boolean {
return (rules ?? []).some((rule) => !!(rule.byDay?.length || rule.byMonthDay?.length || rule.byYearDay?.length));
}
/**
* 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
@@ -133,16 +142,55 @@ interface SeriesFrame {
* 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.
*
* A rule that names the days keeps every occurrence but the anchor on its
* day, at the series' new time, or has it no more: moved by a day, the
* Monday of a series on Mondays and Tuesdays is still that Monday. Moved
* along, its override landed on the Tuesday's and took its place: the
* cancelled Tuesday came back with the Monday's title, and the Monday lost
* its change (live on 1.0 and 0.16.23).
*/
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 (namesDays(to.recurrenceRules) && wallClockMs(key) !== wallClockMs(anchor)) {
const onItsDay = `${key.slice(0, 10)}${moved.slice(10)}`;
return seriesOccursAt(to.start, to.recurrenceRules, onItsDay) ? onItsDay : null;
}
if (seriesOccursAt(to.start, to.recurrenceRules, moved)) return moved;
if (moved !== key && seriesOccursAt(to.start, to.recurrenceRules, key)) return key;
return null;
}
/**
* The key each of `overrides` keeps in the series `to` (overrideKeyIn), by
* its key in `from`, never two on one occurrence. Two still meet when the
* anchor's override moves onto the day of another - a series of Mondays and
* Tuesdays that starts a day later - or when an hourly series turns all-day.
* The override that stays where it is, else the one that stays on its day,
* else the first keeps the occurrence; the others are left out, and
* logged, rather than written over it.
*/
function overrideKeysIn(overrides: OverrideMap, from: SeriesFrame, to: SeriesFrame, anchor?: string): Map<string, string> {
const claims = new Map<string, string[]>();
for (const key of Object.keys(overrides)) {
const target = overrideKeyIn(key, from, to, anchor);
if (target) claims.set(target, [...(claims.get(target) ?? []), key]);
}
const keys = new Map<string, string>();
for (const [target, claimed] of claims) {
const kept = claimed.find((key) => key === target)
?? claimed.find((key) => key.slice(0, 10) === target.slice(0, 10))
?? claimed[0];
keys.set(kept, target);
if (claimed.length > 1) {
debug.warn('calendar', 'Recurrence overrides that meet on one occurrence; keeping one', target, kept, claimed);
}
}
return keys;
}
/**
* `entry` with its attendees asked again (participantsAskedAgain): a whole
* participants map (0.16) with their answers back at needs-action, a
@@ -385,15 +433,15 @@ function seriesParticipants(
* 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).
* (overrideKeysIn), and an override of an occurrence the series no longer
* has is dropped - every one when the series no longer repeats. 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
* it; 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,
@@ -414,12 +462,18 @@ function seriesOverrides(
showWithoutTime: series.showWithoutTime ?? !!master.showWithoutTime,
recurrenceRules: options.ruleChanged ? series.recurrenceRules ?? null : master.recurrenceRules,
};
// "Repeat: none": one event is left, with no occurrence to override. The
// override of its start stayed: 1.0 applied it to the event - excluded, the
// event was gone from every range - and 0.16.23 listed a changed one next
// to the event (live).
if (options.ruleChanged && !to.recurrenceRules?.length) return null;
const turned = from.showWithoutTime !== to.showWithoutTime;
const opened = resolveOverrideKey({ start: occurrence.start, recurrenceId: occurrence.recurrenceId ?? master.start }, overrides);
const targets = overrideKeysIn(overrides, from, to);
const result: OverrideMap = {};
for (const [key, override] of Object.entries(overrides)) {
const target = overrideKeyIn(key, from, to);
const target = targets.get(key);
if (!target) continue;
const { updated: _updated, ...rest } = override as OverrideEntry;
let entry: OverrideEntry = rest;
@@ -432,7 +486,11 @@ function seriesOverrides(
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);
// One that only held its occurrence's time holds the time the
// occurrence has now; the opened one, moved on its own, takes the
// editor's move.
if (pinned) entry.start = movedLike(entry.start, key, target);
else if (key === opened) entry.start = movedLike(entry.start, from.start, to.start);
}
if (resized && typeof entry.duration === 'string'
&& (key === opened || parseDurationSeconds(entry.duration) === parseDurationSeconds(master.duration))) {
@@ -715,7 +773,7 @@ function remainingRules(
/**
* 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
* each with its occurrence (overrideKeysIn). 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
@@ -738,9 +796,10 @@ function followingOverrides(
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 targets = overrideKeysIn(overrides, from, to, splitKey);
const result: OverrideMap = {};
for (const [key, override] of Object.entries(overrides)) {
const target = overrideKeyIn(key, from, to, splitKey);
const target = targets.get(key);
if (!target) continue;
const opened = key === splitKey;
const { updated: _updated, ...rest } = override as OverrideEntry;
@@ -785,6 +844,7 @@ const FOLLOWING_SERIES_KEYS = [
* 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
@@ -841,6 +901,7 @@ 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 },