diff --git a/lib/__tests__/recurrence-scope-edit.test.ts b/lib/__tests__/recurrence-scope-edit.test.ts index 05f7289f7..7896ef88e 100644 --- a/lib/__tests__/recurrence-scope-edit.test.ts +++ b/lib/__tests__/recurrence-scope-edit.test.ts @@ -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, 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) => + (updates.recurrenceOverrides === undefined ? master.recurrenceOverrides : updates.recurrenceOverrides) as Record>; + + 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>; + 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>; + 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) => ({ + ...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; + + 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 diff --git a/lib/recurrence-scope-edit.ts b/lib/recurrence-scope-edit.ts index bedf5eb3a..cefde361b 100644 --- a/lib/recurrence-scope-edit.ts +++ b/lib/recurrence-scope-edit.ts @@ -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 { + const claims = new Map(); + 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(); + 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 },