diff --git a/lib/__tests__/recurrence-expansion.test.ts b/lib/__tests__/recurrence-expansion.test.ts index 405d99e73..97202a584 100644 --- a/lib/__tests__/recurrence-expansion.test.ts +++ b/lib/__tests__/recurrence-expansion.test.ts @@ -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); + }); +}); diff --git a/lib/__tests__/recurrence-scope-edit.test.ts b/lib/__tests__/recurrence-scope-edit.test.ts index 83295eb6b..9cb443e0d 100644 --- a/lib/__tests__/recurrence-scope-edit.test.ts +++ b/lib/__tests__/recurrence-scope-edit.test.ts @@ -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 = {}) => ({ + 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 = {}) => ({ 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>; + 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>; + 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(); }); diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts index 22bbb3b32..113bf5edc 100644 --- a/lib/recurrence-expansion.ts +++ b/lib/recurrence-expansion.ts @@ -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) { diff --git a/lib/recurrence-scope-edit.ts b/lib/recurrence-scope-edit.ts index 3e423adac..ed2731baf 100644 --- a/lib/recurrence-scope-edit.ts +++ b/lib/recurrence-scope-edit.ts @@ -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; +type OverrideEntry = Record; + +/** 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, + 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, scheduling: { sendScheduling?: boolean; userEmails?: string[] } = {}, -): Promise<{ originalRules: CalendarEvent['recurrenceRules']; announced: true | undefined }> { +): Promise<{ restore: Partial; 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 = { recurrenceRules: truncatedRules }; + const restore: Record = { 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, announced); + return { restore: restore as Partial, 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, originalRules: CalendarEvent['recurrenceRules'], + options: { editor?: EventEditorState; overrides?: OverrideMap } = {}, ): Partial { + 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 = { 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); }