diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 52219ea1..f87bcc2a 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -66,7 +66,7 @@ import { userOrganizes, type Attendee, } from '../../lib/calendar-participants'; -import type { SeriesEdit } from '../../lib/recurrence-overrides'; +import type { SeriesEdit } from '../../lib/recurrence-scope-edit'; import { Button } from '..'; import { ParticipantInput } from './ParticipantInput'; import { useContactNameResolver } from '../../lib/contact-name-resolver'; @@ -373,7 +373,9 @@ export function EventModal({ // Only the organizer sends: on an invitation someone else sent, a // save would reach the organizer as a reply. sendSchedulingMessages: organizes && attendees.length > 0 && sendInvitations, - edit: { attendees, timesMoved, ruleChanged }, + // The dates as shown: a series moves by the occurrence's move, on + // its own clock (lib/recurrence-scope-edit). + edit: { attendees, when: { allDay, start, end }, ruleChanged }, }); onClose(); } catch (err) { diff --git a/src/lib/__tests__/calendar-participants.test.ts b/src/lib/__tests__/calendar-participants.test.ts index bdb0e855..1d5878d6 100644 --- a/src/lib/__tests__/calendar-participants.test.ts +++ b/src/lib/__tests__/calendar-participants.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { CalendarEvent } from '../../api/types'; import { - askAttendeesAgain, buildParticipantMap, collectUserCalendarAddresses, getParticipantCount, @@ -13,6 +12,7 @@ import { participantFieldsToSave, participantLimitMessage, participantLimitOf, + participantsAskedAgain, seedAttendees, seriesParticipantFields, userOrganizes, @@ -349,16 +349,32 @@ describe('answers on save (Stalwart 0.16 and 1.0)', () => { it('tells whether anyone but the organizer answered', () => { expect(hasAnsweredAttendees(meeting.participants)).toBe(true); - expect(hasAnsweredAttendees(askAttendeesAgain(meeting.participants!))).toBe(false); + expect(hasAnsweredAttendees(participantsAskedAgain(meeting))).toBe(false); expect(hasAnsweredAttendees(null)).toBe(false); }); it('asks everyone but the organizer again for a new series', () => { - const asked = askAttendeesAgain(meeting.participants!, ['me@example.com']); + const asked = participantsAskedAgain(meeting)!; expect(asked.p0.participationStatus).toBe('accepted'); expect([asked.p1, asked.p2, asked.p3].map((p) => p.participationStatus)).toEqual(['needs-action', 'needs-action', 'needs-action']); }); + + it('knows the organizer by its address when the server gives it no role, and asks nobody when nobody answered', () => { + const roleless = { + organizerCalendarAddress: 'mailto:me@example.com', + participants: { + p0: { calendarAddress: 'mailto:me@example.com', participationStatus: 'accepted' }, + p1: { calendarAddress: 'mailto:bob@example.com', participationStatus: 'accepted' }, + }, + } as Partial; + + expect(participantsAskedAgain(roleless)).toEqual({ + p0: roleless.participants!.p0, + p1: { ...roleless.participants!.p1, participationStatus: 'needs-action' }, + }); + expect(participantsAskedAgain({ participants: { p3: meeting.participants!.p3 } })).toBeNull(); + }); }); describe('seriesParticipantFields (all occurrences)', () => { diff --git a/src/lib/__tests__/recurrence-expansion.test.ts b/src/lib/__tests__/recurrence-expansion.test.ts index 2d360b63..ae634d4b 100644 --- a/src/lib/__tests__/recurrence-expansion.test.ts +++ b/src/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-alert-scheduler'; import type { CalendarEvent, RecurrenceRule } from '../../api/types'; @@ -492,3 +492,44 @@ describe('expandRecurringEvents - the master\'s instants', () => { ]); }); }); + +// What the scope edits ask 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', () => { + const rule = (partial: Partial): RecurrenceRule => ({ '@type': 'RecurrenceRule', frequency: 'weekly', ...partial }); + + 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: RecurrenceRule[] = [{ '@type': 'RecurrenceRule', 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', [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], '2026-12-23T00:00:00')).toBe(true); + }); +}); diff --git a/src/lib/__tests__/recurrence-overrides.test.ts b/src/lib/__tests__/recurrence-overrides.test.ts index 9b75cc86..d34178a2 100644 --- a/src/lib/__tests__/recurrence-overrides.test.ts +++ b/src/lib/__tests__/recurrence-overrides.test.ts @@ -1,14 +1,9 @@ import { describe, it, expect } from 'vitest'; import type { CalendarEvent } from '../../api/types'; -import { - buildAllScopeEdit, - buildAllScopeUpdates, - buildFutureSeriesData, - buildFutureSeriesEdit, - isRecurringSeriesMember, - truncateRecurrenceRules, -} from '../recurrence-overrides'; -import { seedAttendees } from '../calendar-participants'; +import { isRecurringSeriesMember } from '../recurrence-overrides'; + +// The scope edits ("this", "this and following", "all") are tested in +// recurrence-scope-edit.test.ts. const master: CalendarEvent = { id: 'ev-1', @@ -23,10 +18,10 @@ const master: CalendarEvent = { const occurrence: CalendarEvent = { ...master, - id: 'ev-1:2026-03-04T08:00:00.000Z', + id: 'ev-1:2026-03-04T09:00:00', originalId: 'ev-1', start: '2026-03-04T09:00:00', - recurrenceId: '2026-03-04T08:00:00.000Z', + recurrenceId: '2026-03-04T09:00:00', }; describe('isRecurringSeriesMember', () => { @@ -37,174 +32,3 @@ describe('isRecurringSeriesMember', () => { expect(isRecurringSeriesMember({ originalId: 'raw' } as CalendarEvent)).toBe(false); }); }); - -describe('truncateRecurrenceRules', () => { - it('ends the rule one second before the occurrence slot and drops count', () => { - const rules = truncateRecurrenceRules(master.recurrenceRules, occurrence); - expect(rules).toHaveLength(1); - expect(rules[0].count).toBeUndefined(); - // recurrenceId is the UTC instant; until is local wall-clock, one second earlier. - const expected = new Date('2026-03-04T08:00:00.000Z'); - expected.setSeconds(expected.getSeconds() - 1); - const pad = (n: number) => String(n).padStart(2, '0'); - const local = - `${expected.getFullYear()}-${pad(expected.getMonth() + 1)}-${pad(expected.getDate())}` + - `T${pad(expected.getHours())}:${pad(expected.getMinutes())}:${pad(expected.getSeconds())}`; - expect(rules[0].until).toBe(local); - expect(rules[0].frequency).toBe('daily'); - }); - - it('treats an all-day recurrenceId as a local date', () => { - const rules = truncateRecurrenceRules( - [{ frequency: 'weekly' }], - { recurrenceId: '2026-03-04', start: '2026-03-04T00:00:00' }, - ); - expect(rules[0].until).toBe('2026-03-03T23:59:59'); - }); -}); - -describe('buildAllScopeUpdates', () => { - it('drops an unchanged occurrence start so the master is not moved', () => { - const out = buildAllScopeUpdates( - { title: 'Renamed', start: '2026-03-04T09:00:00', recurrenceId: 'x' }, - occurrence, - master, - ); - expect(out).toEqual({ title: 'Renamed' }); - }); - - it('applies the delta of a moved occurrence to the master start', () => { - const out = buildAllScopeUpdates( - { start: '2026-03-04T10:30:00' }, - occurrence, - master, - ); - expect(out.start).toBe('2026-03-02T10:30:00'); - }); - - it('keeps start when editing the master itself', () => { - const out = buildAllScopeUpdates({ start: '2026-03-05T09:00:00' }, master, master); - expect(out.start).toBe('2026-03-05T09:00:00'); - }); -}); - -describe('buildFutureSeriesData', () => { - it('clones the master with the original rules, edits on top and the occurrence start', () => { - const data = buildFutureSeriesData( - master, - master.recurrenceRules ?? null, - occurrence, - { title: 'New title' }, - ); - expect(data.title).toBe('New title'); - expect(data.start).toBe('2026-03-04T09:00:00'); - expect(data.recurrenceRules).toEqual(master.recurrenceRules); - expect(data.duration).toBe('PT30M'); - expect(data.timeZone).toBe('Europe/Berlin'); - expect('id' in data).toBe(false); - expect('uid' in data).toBe(false); - expect('recurrenceId' in data).toBe(false); - }); - - it('prefers a start the user moved', () => { - const data = buildFutureSeriesData(master, null, occurrence, { start: '2026-03-04T11:00:00' }); - expect(data.start).toBe('2026-03-04T11:00:00'); - expect('recurrenceRules' in data).toBe(false); - }); -}); - -describe('series edits from an occurrence the user organizes', () => { - const participants = { - p0: { calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' }, - p1: { calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, - } as CalendarEvent['participants']; - const series: CalendarEvent = { ...master, organizerCalendarAddress: 'mailto:me@example.com', participants }; - // The fourth occurrence, stretched to an hour, which Bob declined. - const fourth: CalendarEvent = { - ...series, - id: 'ev-1:2026-03-05T09:00:00', - start: '2026-03-05T09:00:00', - duration: 'PT1H', - recurrenceId: '2026-03-05T09:00:00', - participants: { ...participants, p1: { ...participants!.p1, participationStatus: 'declined' } }, - }; - const me = { userEmail: 'me@example.com', userEmails: ['me@example.com'] }; - // What the editor saves for an unchanged form: the occurrence's own times, - // the rule written back and the occurrence's participants. - const formSave = { - title: 'Standup (moved room)', - start: fourth.start, - duration: fourth.duration, - timeZone: fourth.timeZone, - showWithoutTime: false, - recurrenceRules: [{ frequency: 'daily', interval: 1, count: 10 }], - participants: fourth.participants, - } as Partial; - const attendees = seedAttendees(fourth, ['me@example.com']); - - it('changes only what the user changed when nothing moved (all occurrences)', () => { - const update = buildAllScopeEdit(formSave, fourth, series, { attendees, timesMoved: false, ruleChanged: false }, me); - - // Not the fourth's length, rule rewrite or participants (Bob's decline). - expect(update).toEqual({ title: 'Standup (moved room)' }); - }); - - it('moves the series by the occurrence\'s delta and asks its attendees again (all occurrences)', () => { - const moved = { ...formSave, start: '2026-03-05T10:00:00' }; - - const update = buildAllScopeEdit(moved, fourth, series, { attendees, timesMoved: true, ruleChanged: false }, me); - - expect(update.start).toBe('2026-03-02T10:00:00'); - const statuses = Object.values(update.participants ?? {}).map((p) => [p.calendarAddress, p.participationStatus]); - expect(statuses).toEqual([['mailto:me@example.com', 'accepted'], ['mailto:bob@example.com', 'needs-action']]); - }); - - it('asks every attendee of the new series again (this and following)', () => { - const data = buildFutureSeriesEdit(series, series.recurrenceRules!, fourth, formSave, ['me@example.com']); - - const statuses = Object.values(data.participants ?? {}).map((p) => p.participationStatus); - expect(statuses).toEqual(['accepted', 'needs-action']); - }); - - it('leaves the answers of a series someone else organizes alone (this and following)', () => { - const invitation = { ...series, organizerCalendarAddress: 'mailto:boss@example.com', participants: { - ...participants, p0: { ...participants!.p0, calendarAddress: 'mailto:boss@example.com' }, - } } as CalendarEvent; - - const data = buildFutureSeriesEdit(invitation, invitation.recurrenceRules!, fourth, {}, ['me@example.com']); - - expect(Object.values(data.participants ?? {}).map((p) => p.participationStatus)).toEqual(['accepted', 'accepted']); - }); - - // The series in the owner's calendar, shared with the user read & write: - // Stalwart sends the user's change from the owner (isOrigin, 0.16 and 1.0). - it('asks the attendees again when the user changes the owner\'s series in a shared calendar', () => { - const owners = { - ...series, - isOrigin: true, - organizerCalendarAddress: 'mailto:owner@example.com', - participants: { ...participants, p0: { ...participants!.p0, calendarAddress: 'mailto:owner@example.com' } }, - } as CalendarEvent; - const occurrenceOfOwners = { ...fourth, isOrigin: true, organizerCalendarAddress: owners.organizerCalendarAddress, participants: { - ...owners.participants, p1: { ...owners.participants!.p1, participationStatus: 'declined' }, - } } as CalendarEvent; - const sharee = { userEmail: 'sharee@example.com', userEmails: ['sharee@example.com'] }; - const rows = seedAttendees(occurrenceOfOwners, ['sharee@example.com']); - - const following = buildFutureSeriesEdit(owners, owners.recurrenceRules!, occurrenceOfOwners, { - participants: owners.participants, - }, ['sharee@example.com']); - const moved = buildAllScopeEdit({ ...formSave, start: '2026-03-05T10:00:00' }, occurrenceOfOwners, owners, { - attendees: rows, timesMoved: true, ruleChanged: false, - }, sharee); - - expect(Object.values(following.participants ?? {}).map((p) => p.participationStatus)).toEqual(['accepted', 'needs-action']); - expect(Object.values(moved.participants ?? {}).map((p) => [p.calendarAddress, p.participationStatus])) - .toEqual([['mailto:owner@example.com', 'accepted'], ['mailto:bob@example.com', 'needs-action']]); - }); - - it('keeps writing the times when the flags are not given', () => { - expect(buildAllScopeUpdates({ start: '2026-03-05T09:00:00', duration: 'PT1H' }, fourth, series)) - .toEqual({ duration: 'PT1H' }); - }); -}); diff --git a/src/lib/__tests__/recurrence-scope-edit.test.ts b/src/lib/__tests__/recurrence-scope-edit.test.ts new file mode 100644 index 00000000..e1fea595 --- /dev/null +++ b/src/lib/__tests__/recurrence-scope-edit.test.ts @@ -0,0 +1,978 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// The device and the calendar are in Berlin unless a test says otherwise. +vi.hoisted(() => { + process.env.TZ = 'Europe/Berlin'; +}); + +import { addDays, addHours, addMinutes, startOfDay } from 'date-fns'; +import { + deleteOccurrence, + followingSeriesData, + saveOccurrenceEdit, + seriesUpdatesFromOccurrence, + type OccurrenceEditActions, + type SeriesEdit, +} from '../recurrence-scope-edit'; +import { expandRecurringEvents } from '../recurrence-expansion'; +import { getEventStartDate } from '../calendar-utils'; +import { editorTimesFromEvent } from '../event-editor-times'; +import { useSettingsStore } from '../../stores/settings-store'; +import type { CalendarEvent, Participant } from '../../api/types'; + +// "All occurrences" from the editor opened on one occurrence: the editor's +// dates and participants are that occurrence's. Written to the series as +// they were, a rename from the third occurrence moved the series' start to +// it (the first two occurrences gone) and gave every occurrence the third +// one's answers - here userb's "declined" for that day only. + +type Version = '0.16' | '1.0'; +const ORGANIZER = 'userc@example.org'; +const ATTENDEE = 'userb@example.org'; +// Someone userc shares the calendar with, read & write; not invited. +const SHAREE = 'usera@example.org'; + +// Participants as each version stores them: 0.16.23 drops an attendee's +// roles, 1.0 keeps them. +function participant(version: Version, address: string, owner: boolean, status: string): Participant { + return { + '@type': 'Participant', calendarAddress: `mailto:${address}`, email: address, + ...(owner ? { roles: { owner: true } } : version === '1.0' ? { roles: { attendee: true } } : {}), + participationStatus: status, scheduleAgent: 'server', expectReply: !owner, kind: 'individual', + } as unknown as Participant; +} + +const statusOf = (participants: CalendarEvent['participants'] | undefined, address: string) => + Object.values(participants ?? {}).find((p) => p.calendarAddress === `mailto:${address}`)?.participationStatus; + +// Weekly on Mondays from 5 October, 10:00 in Berlin, five times. userb +// accepted the series and declined 19 October alone: 1.0 returns that +// override as a PatchObject, 0.16 as the whole participants map. +function series(version: Version): CalendarEvent { + const participants = { + org: participant(version, ORGANIZER, true, 'accepted'), + b: participant(version, ATTENDEE, false, 'accepted'), + }; + return { + id: 'b', uid: 'weekly@example.org', '@type': 'Event', title: 'Planning', + start: '2026-10-05T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, + utcStart: '2026-10-05T08:00:00Z', utcEnd: '2026-10-05T09:00:00Z', + calendarIds: { cal: true }, organizerCalendarAddress: `mailto:${ORGANIZER}`, participants, isOrigin: true, + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 }], + recurrenceOverrides: version === '1.0' + ? { '2026-10-19T10:00:00': { 'participants/b/participationStatus': 'declined' } } + : { '2026-10-19T10:00:00': { participants: { ...participants, b: participant(version, ATTENDEE, false, 'declined') } } }, + excludedRecurrenceRules: null, alerts: null, + } as unknown as CalendarEvent; +} + +// 19 October as the server expands it (0.16.20 on, and 1.0): a synthetic id +// naming its base event, hydrated from it. +function serverOccurrence(version: Version): CalendarEvent { + const base = series(version); + return { + ...base, + id: 'maaaaac', baseEventId: 'b', + start: '2026-10-19T10:00:00', utcStart: '2026-10-19T08:00:00Z', utcEnd: '2026-10-19T09:00:00Z', + recurrenceId: '2026-10-19T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin', + participants: { ...base.participants, b: participant(version, ATTENDEE, false, 'declined') }, + } as CalendarEvent; +} + +// 19 October as the device expands it (0.16 before 0.16.20). +function deviceOccurrence(): CalendarEvent { + const occurrences = expandRecurringEvents([series('0.16')], '2026-10-01T00:00:00', '2026-11-30T00:00:00'); + return occurrences.find((o) => o.recurrenceId === '2026-10-19T10:00:00')!; +} + +const occurrences: Array<[string, Version, () => CalendarEvent]> = [ + ['1.0', '1.0', () => serverOccurrence('1.0')], + ['0.16.20+', '0.16', () => serverOccurrence('0.16')], + ['0.16 before 0.16.20', '0.16', deviceOccurrence], +]; + +/** What the editor had, opened on `occurrence` and saved as it came unless changed. */ +function editorOn(occurrence: CalendarEvent, change: Partial = {}): SeriesEdit { + return { + attendees: [{ name: '', email: ATTENDEE }], + when: editorTimesFromEvent(occurrence), + ...change, + }; +} + +/** What the editor writes for `occurrence` (EventModal handleSave) with `change`. */ +function editorUpdates(occurrence: CalendarEvent, change: Partial = {}): Partial { + return { + title: occurrence.title, start: occurrence.start, duration: occurrence.duration, + timeZone: occurrence.timeZone, showWithoutTime: false, recurrenceRules: occurrence.recurrenceRules, + // The occurrence's participants, rebuilt with its answers. + participants: occurrence.participants, + ...change, + }; +} + +const context = { userEmail: ORGANIZER, userEmails: [ORGANIZER] }; + +/** The calendar store's actions as the scope code uses them. */ +function actions(master: CalendarEvent | null) { + return { + updateEvent: vi.fn(async (_id: string, _changes: Partial | Record, _options?: { sendSchedulingMessages?: boolean }) => undefined), + // The created event, or nothing when a store answers a failure so. + createEvent: vi.fn(async (event: Partial, _calendarId: string, _options?: { sendSchedulingMessages?: boolean }) => + ({ ...event, id: 'n1' }) as CalendarEvent | null), + getMasterEvent: vi.fn(async (_occurrence: CalendarEvent) => master), + deleteEvent: vi.fn(async (_id: string) => undefined), + } satisfies OccurrenceEditActions & { deleteEvent: (id: string) => Promise }; +} + +beforeEach(() => useSettingsStore.setState({ calendarTimeZone: 'Europe/Berlin' })); +afterEach(() => useSettingsStore.setState({ calendarTimeZone: 'auto' })); + +describe.each(occurrences)('changing every occurrence from one of them on Stalwart %s', (_label, version, make) => { + it('renames the series and keeps when it happens and its answers', () => { + const occurrence = make(); + const updates = seriesUpdatesFromOccurrence( + occurrence, series(version), editorUpdates(occurrence, { title: 'Planning, room 2' }), editorOn(occurrence), context, + ); + + expect(updates.title).toBe('Planning, room 2'); + for (const key of ['start', 'duration', 'timeZone', 'showWithoutTime', 'recurrenceId', 'participants', 'organizerCalendarAddress', 'recurrenceRules']) { + expect(updates).not.toHaveProperty(key); + } + }); + + it('moves the series from its own start by as much as the occurrence moved, and asks again', () => { + const occurrence = make(); + const { start, end } = editorTimesFromEvent(occurrence); + const updates = seriesUpdatesFromOccurrence( + occurrence, series(version), + editorUpdates(occurrence, { start: '2026-10-19T11:30:00' }), + editorOn(occurrence, { when: { allDay: false, start: addMinutes(start, 90), end: addMinutes(end, 90) } }), + context, + ); + + expect(updates.start).toBe('2026-10-05T11:30:00'); + expect(updates).not.toHaveProperty('duration'); + expect(updates).not.toHaveProperty('timeZone'); + // Asked again as the series' organizer: the series' answer, not the + // occurrence's "declined", goes back to needs-action. + expect(statusOf(updates.participants, ORGANIZER)).toBe('accepted'); + expect(statusOf(updates.participants, ATTENDEE)).toBe('needs-action'); + }); + + it('moves the series to another weekday by the days the occurrence moved', () => { + const occurrence = make(); + const { start, end } = editorTimesFromEvent(occurrence); + const updates = seriesUpdatesFromOccurrence( + occurrence, series(version), editorUpdates(occurrence), + editorOn(occurrence, { when: { allDay: false, start: addDays(start, 1), end: addDays(end, 1) } }), + context, + ); + + expect(updates.start).toBe('2026-10-06T10:00:00'); + }); + + it('changes only the length when only the end moved', () => { + const occurrence = make(); + const { start, end } = editorTimesFromEvent(occurrence); + const updates = seriesUpdatesFromOccurrence( + occurrence, series(version), editorUpdates(occurrence, { duration: 'PT1H30M' }), + editorOn(occurrence, { when: { allDay: false, start, end: addMinutes(end, 30) } }), + context, + ); + + expect(updates.duration).toBe('PT1H30M'); + expect(updates).not.toHaveProperty('start'); + expect(statusOf(updates.participants, ATTENDEE)).toBe('needs-action'); + }); + + it('turns the series all-day on its own first day', () => { + const occurrence = make(); + const day = startOfDay(getEventStartDate(occurrence)); + const updates = seriesUpdatesFromOccurrence( + occurrence, series(version), + editorUpdates(occurrence, { start: '2026-10-19T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true }), + editorOn(occurrence, { when: { allDay: true, start: day, end: day } }), + context, + ); + + expect(updates).toMatchObject({ start: '2026-10-05T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true }); + }); + + it('adds the attendee the user added to the series\' own participants', () => { + const occurrence = make(); + const attendees = [{ name: '', email: ATTENDEE }, { name: 'Anna', email: 'usera@example.org' }]; + const updates = seriesUpdatesFromOccurrence( + occurrence, series(version), editorUpdates(occurrence), editorOn(occurrence, { attendees }), context, + ); + + const participants = updates.participants as Record; + // The series' ids and answers: userb accepted the series. + expect(participants.org.calendarAddress).toBe(`mailto:${ORGANIZER}`); + expect(participants.b.participationStatus).toBe('accepted'); + expect(statusOf(participants, 'usera@example.org')).toBe('needs-action'); + expect(Object.keys(participants)).toHaveLength(3); + expect(updates).not.toHaveProperty('organizerCalendarAddress'); + }); + + it('removes the attendee the user removed, and the participants with the last one', () => { + const occurrence = make(); + const updates = seriesUpdatesFromOccurrence( + occurrence, series(version), editorUpdates(occurrence, { participants: null }), editorOn(occurrence, { attendees: [] }), context, + ); + + expect(updates.participants).toBeNull(); + expect(updates.organizerCalendarAddress).toBeNull(); + }); + + it('asks the attendees again when the series gets another rule', () => { + const occurrence = make(); + const updates = seriesUpdatesFromOccurrence( + occurrence, series(version), + editorUpdates(occurrence, { recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 }] }), + editorOn(occurrence, { ruleChanged: true }), context, + ); + + expect(updates).not.toHaveProperty('start'); + expect(updates.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 }]); + expect(statusOf(updates.participants, ATTENDEE)).toBe('needs-action'); + }); + + it('moves an attendee\'s own copy of the series and leaves its participants alone', () => { + const occurrence = make(); + const { start, end } = editorTimesFromEvent(occurrence); + const updates = seriesUpdatesFromOccurrence( + occurrence, { ...series(version), isOrigin: false }, editorUpdates(occurrence, { participants: undefined }), + editorOn(occurrence, { when: { allDay: false, start: addHours(start, 1), end: addHours(end, 1) } }), + { userEmail: ATTENDEE, userEmails: [ATTENDEE] }, + ); + + expect(updates.start).toBe('2026-10-05T11:00:00'); + expect(updates).not.toHaveProperty('participants'); + }); + + // The series in userc's calendar, shared with usera read & write: Stalwart + // sends usera's change from userc (isOrigin: true). + it('asks again when the user moves the owner\'s series in a calendar shared with them', () => { + const occurrence = make(); + const { start, end } = editorTimesFromEvent(occurrence); + const updates = seriesUpdatesFromOccurrence( + occurrence, { ...series(version), isOrigin: true, isShared: true, accountId: 'f' }, editorUpdates(occurrence), + editorOn(occurrence, { when: { allDay: false, start: addHours(start, 1), end: addHours(end, 1) } }), + { userEmail: SHAREE, userEmails: [SHAREE] }, + ); + + expect(updates.start).toBe('2026-10-05T11:00:00'); + expect(statusOf(updates.participants, ORGANIZER)).toBe('accepted'); + 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', () => { + it('moves the series on its own wall clock, whatever zone the calendar shows it in', () => { + useSettingsStore.setState({ calendarTimeZone: 'Asia/Tokyo' }); + // 08:00 in New York is 21:00 in Tokyo; the user moves it to 22:00. + const base = { + ...series('1.0'), start: '2026-10-05T08:00:00', timeZone: 'America/New_York', + utcStart: '2026-10-05T12:00:00Z', utcEnd: '2026-10-05T13:00:00Z', + } as CalendarEvent; + const occurrence = { + ...base, id: 'maaaaac', baseEventId: 'b', start: '2026-10-19T08:00:00', + utcStart: '2026-10-19T12:00:00Z', utcEnd: '2026-10-19T13:00:00Z', recurrenceId: '2026-10-19T08:00:00', + } as CalendarEvent; + const { start, end } = editorTimesFromEvent(occurrence); + expect(start.getHours()).toBe(21); + + const updates = seriesUpdatesFromOccurrence( + occurrence, base, + editorUpdates(occurrence, { start: '2026-10-19T09:00:00' }), + editorOn(occurrence, { when: { allDay: false, start: addHours(start, 1), end: addHours(end, 1) } }), + context, + ); + + expect(updates.start).toBe('2026-10-05T09:00:00'); + expect(updates).not.toHaveProperty('timeZone'); + }); +}); + +// 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 the app 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', + participants, + 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): Partial => { + const { start, end } = editorTimesFromEvent(occurrence); + return { when: { allDay: false, start: addHours(start, hours), end: addHours(end, 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' as const, frequency: 'weekly', interval: 2, count: 5 }]; + 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'); + }); + + it('moves them the same way for an occurrence the device expanded', () => { + const master = seriesWithOverrides(version); + const occurrence = expandRecurringEvents([master], '2026-12-01T00:00:00', '2027-01-31T00:00:00') + .find((o) => o.recurrenceId === '2026-12-23T10:00:00')!; + const updates = seriesUpdatesFromOccurrence( + occurrence, master, editorUpdates(occurrence), editorOn(occurrence, moveBy(occurrence, 1)), context, + ); + + expect(updates.start).toBe('2026-12-09T11:00:00'); + expect(Object.keys(updates.recurrenceOverrides!).sort()).toEqual(['2026-12-16T11:00:00', '2026-12-30T11:00:00', '2027-01-06T11:00:00']); + }); +}); + +// "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('b', { + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-12-21T09:59:59' }], + 'recurrenceOverrides/2027-01-04T10:00:00': null, + }, { sendSchedulingMessages: 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][0].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' as const, frequency: 'daily', count: 10 }]; + + 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][0].recurrenceRules).toEqual(daily); + }); + + it('keeps an UNTIL as it was', async () => { + const master = { ...sixWeeks(null), recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', until: '2027-01-31T23:59:59' }] } as CalendarEvent; + 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.createEvent.mock.calls[0][0].recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly', until: '2027-01-31T23:59:59' }]); + }); + + // 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); + + await deleteOccurrence('this_and_future', { event: occurrenceOf(master, '2026-12-18T10:00:00'), userEmails: [ORGANIZER] }, deps); + + expect(deps.updateEvent).toHaveBeenCalledWith('b', { + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-12-18T09:59:59' }], + 'recurrenceOverrides/2027-01-01T10:00:00': null, + }, { sendSchedulingMessages: true }); + expect(deps.deleteEvent).not.toHaveBeenCalled(); + }); +}); + +// The move of an "all" edit was measured on the calendar's clock. In the week +// Berlin leaves summer time and New York does not yet, 16:00 on Saturday and +// 16:00 on Monday in Berlin are 10:00 and 11:00 in New York: the series went +// to Mondays at 10:00 instead of the 11:00 the user picked. +describe('changing every occurrence across a week only one zone changes clocks in', () => { + it('moves the series by what the move is on its own clock', () => { + // Saturdays 10:00 in New York from 3 October; the calendar is in Berlin. + const master = { + ...series('1.0'), start: '2026-10-03T10:00:00', timeZone: 'America/New_York', + utcStart: '2026-10-03T14:00:00Z', utcEnd: '2026-10-03T15:00:00Z', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], recurrenceOverrides: null, + } as unknown as CalendarEvent; + const occurrence = { + ...master, id: 'maaaaac', baseEventId: 'b', start: '2026-10-24T10:00:00', recurrenceId: '2026-10-24T10:00:00', + utcStart: '2026-10-24T14:00:00Z', utcEnd: '2026-10-24T15:00:00Z', + } as CalendarEvent; + const { start: from } = editorTimesFromEvent(occurrence); + // 16:00 in Berlin on Saturday 24 October, moved to 16:00 on Monday 26 October. + expect(from.getHours()).toBe(16); + const updates = seriesUpdatesFromOccurrence( + occurrence, master, editorUpdates(occurrence, { start: '2026-10-26T11:00:00' }), + editorOn(occurrence, { when: { allDay: false, start: addDays(from, 2), end: addHours(addDays(from, 2), 1) } }), + context, + ); + + expect(updates.start).toBe('2026-10-05T11:00:00'); + }); +}); + +// "All" from an occurrence with an override of its own wrote everything the +// editor showed - the occurrence's own title, copied into its override when +// it got one - to the series: a new room there renamed the series back. +describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('what "all" writes from an overridden occurrence on Stalwart %s', (_label, version) => { + const room = (name: string) => ({ loc1: { '@type': 'Location', name } }) as unknown as CalendarEvent['locations']; + // "Daily sync" weekly from 7 December. 21 December is called "Standup" in + // room 1; 28 December was moved to 14:00 on its own (0.16 copied the + // title then, as it was). + function withOwnTitle(): CalendarEvent { + const copies = version === '1.0' ? {} : { duration: 'PT1H', title: 'Daily sync' }; + return { + ...series(version), title: 'Daily sync', start: '2026-12-07T10:00:00', utcStart: '2026-12-07T09:00:00Z', utcEnd: '2026-12-07T10:00:00Z', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], locations: room('Room 0'), + recurrenceOverrides: { + '2026-12-21T10:00:00': version === '1.0' + ? { title: 'Standup', locations: room('Room 1') } + : { start: '2026-12-21T10:00:00', duration: 'PT1H', title: 'Standup', locations: room('Room 1') }, + '2026-12-28T10:00:00': { start: '2026-12-28T14:00:00', ...copies }, + }, + } as unknown as CalendarEvent; + } + const on = (master: CalendarEvent, recurrenceId: string, own: Partial = {}) => ({ + ...master, id: 'maaaaac', baseEventId: 'b', start: recurrenceId, recurrenceId, + utcStart: berlinUtc(recurrenceId), utcEnd: berlinUtc(recurrenceId, 1), ...own, + }) as CalendarEvent; + + it('writes only the new room to the series, and to that occurrence', () => { + const master = withOwnTitle(); + const occurrence = on(master, '2026-12-21T10:00:00', { title: 'Standup', locations: room('Room 1') }); + const updates = seriesUpdatesFromOccurrence( + occurrence, master, editorUpdates(occurrence, { locations: room('Room 2') }), editorOn(occurrence), context, + ); + + expect(updates).not.toHaveProperty('title'); + expect(updates.locations).toEqual(room('Room 2')); + const own = updates.recurrenceOverrides!['2026-12-21T10:00:00'] as Record; + expect(own).toMatchObject({ title: 'Standup', locations: room('Room 2') }); + }); + + // 1.0 returns the field as pointers when the occurrence's value came from + // the editor under another id (live): the room stayed Room 1 there. + it('replaces pointers into the changed field on that occurrence', () => { + const master = withOwnTitle(); + master.recurrenceOverrides!['2026-12-21T10:00:00'] = { + title: 'Standup', 'locations/loc1/name': 'Room 1', 'locations/l': null, + } as never; + const occurrence = on(master, '2026-12-21T10:00:00', { title: 'Standup', locations: room('Room 1') }); + const updates = seriesUpdatesFromOccurrence( + occurrence, master, editorUpdates(occurrence, { locations: room('Room 2') }), editorOn(occurrence), context, + ); + + expect(updates.recurrenceOverrides!['2026-12-21T10:00:00']).toEqual({ title: 'Standup', locations: room('Room 2') }); + }); + + it('renames the series and the occurrences that only had its old name, not one with its own', () => { + const master = withOwnTitle(); + const occurrence = on(master, '2026-12-14T10:00:00'); + const updates = seriesUpdatesFromOccurrence( + occurrence, master, editorUpdates(occurrence, { title: 'Weekly sync' }), editorOn(occurrence), context, + ); + + expect(updates.title).toBe('Weekly sync'); + if (version === '1.0') { + // 1.0 keeps an override as a patch: 28 December has no title to change. + expect(updates).not.toHaveProperty('recurrenceOverrides'); + } else { + const overrides = updates.recurrenceOverrides as Record>; + expect(overrides['2026-12-28T10:00:00'].title).toBe('Weekly sync'); + expect(overrides['2026-12-21T10:00:00'].title).toBe('Standup'); + } + }); +}); + +// "This and following" started the new series at the opened occurrence's own +// time and in the calendar's zone. Mondays 10:00 in New York, 21 December +// moved alone to Tuesday: renamed from there by a user in Berlin, the rest +// became a Tuesday series at 16:00 in Berlin. +describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('where "this and following" goes on on Stalwart %s', (_label, version) => { + function mondays(): CalendarEvent { + return { + ...series(version), start: '2026-12-07T10:00:00', timeZone: 'America/New_York', + utcStart: '2026-12-07T15:00:00Z', utcEnd: '2026-12-07T16:00:00Z', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], + recurrenceOverrides: { + '2026-12-21T10:00:00': version === '1.0' + ? { start: '2026-12-22T10:00:00', title: 'Planning, Tuesday' } + : { start: '2026-12-22T10:00:00', duration: 'PT1H', title: 'Planning, Tuesday' }, + }, + } as unknown as CalendarEvent; + } + // 21 December as the server hands it out: on Tuesday, 16:00 in Berlin. + const tuesday = (master: CalendarEvent) => ({ + ...master, id: 'maaaaac', baseEventId: 'b', start: '2026-12-22T10:00:00', recurrenceId: '2026-12-21T10:00:00', + recurrenceIdTimeZone: 'America/New_York', title: 'Planning, Tuesday', + utcStart: '2026-12-22T15:00:00Z', utcEnd: '2026-12-22T16:00:00Z', + }) as CalendarEvent; + + async function split(change: Partial, hours = 0) { + const master = mondays(); + const occurrence = tuesday(master); + const deps = actions(master); + await saveOccurrenceEdit('this_and_future', { + event: occurrence, + updates: editorUpdates(occurrence, change), + editor: editorOn(occurrence, hours ? moveBy(occurrence, hours) : {}), + sendScheduling: true, userEmails: [ORGANIZER], + }, deps); + return deps.createEvent.mock.calls[0][0]; + } + + it('goes on from the occurrence\'s place in the series, in the series\' zone, the moved occurrence staying where it was', async () => { + const created = await split({ description: 'New agenda' }); + + expect(created).toMatchObject({ start: '2026-12-21T10:00:00', timeZone: 'America/New_York', description: 'New agenda' }); + // The series' title, not the occurrence's own; that one stays with it. + expect(created.title).toBe('Planning'); + expect(created.recurrenceOverrides!['2026-12-21T10:00:00']).toMatchObject({ start: '2026-12-22T10:00:00', title: 'Planning, Tuesday' }); + }); + + it('moves the new series and the occurrence by what the editor moved it', async () => { + const created = await split({}, 1); + + expect(created).toMatchObject({ start: '2026-12-21T11:00:00', timeZone: 'America/New_York' }); + expect(created.recurrenceOverrides!['2026-12-21T11:00:00']).toMatchObject({ start: '2026-12-22T11:00:00' }); + }); +}); + +describe('saveOccurrenceEdit for every occurrence', () => { + it('writes the series\' own change to its base event on Stalwart 1.0', async () => { + const occurrence = serverOccurrence('1.0'); + const deps = actions({ ...series('1.0'), recurrenceOverrides: null }); + + const saved = await saveOccurrenceEdit('all', { + event: occurrence, + updates: editorUpdates(occurrence, { title: 'Planning, room 2' }), + editor: editorOn(occurrence), + sendScheduling: true, + userEmails: [ORGANIZER], + }, deps); + + expect(saved).toBe(true); + expect(deps.getMasterEvent).toHaveBeenCalledWith(occurrence); + // What the user changed, and only that: the rule the editor wrote as it + // was stays the series' own. + expect(deps.updateEvent).toHaveBeenCalledWith('b', { title: 'Planning, room 2' }, { sendSchedulingMessages: true }); + }); + + it('reports a series it cannot find', async () => { + const occurrence = serverOccurrence('1.0'); + const deps = actions(null); + + const saved = await saveOccurrenceEdit('all', { + event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), userEmails: [ORGANIZER], + }, deps); + + expect(saved).toBe(false); + expect(deps.updateEvent).not.toHaveBeenCalled(); + }); +}); + +// "This and the following occurrences" ends the series before the +// occurrence and makes a new series of the rest. The new series kept the +// answers, and Stalwart invites to a new event only attendees without one: +// everyone who had answered lost the following occurrences without a word. +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(); + const deps = actions(series(version)); + + const saved = await saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence, { title: 'Planning, room 2' }), editor: editorOn(occurrence), + calendarId: 'cal', sendScheduling: true, userEmails: [ORGANIZER], + }, deps); + + expect(saved).toBe(true); + expect(deps.updateEvent).toHaveBeenCalledTimes(1); + expect(deps.updateEvent).toHaveBeenCalledWith('b', truncation, { sendSchedulingMessages: true }); + const [created, calendarId, options] = deps.createEvent.mock.calls[0]; + expect([calendarId, options]).toEqual(['cal', { sendSchedulingMessages: true }]); + // 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. The store puts it in the calendar. + for (const key of ['id', 'uid', 'recurrenceId', 'recurrenceOverrides', 'calendarIds', 'isOrigin']) expect(created).not.toHaveProperty(key); + expect(statusOf(created.participants, ORGANIZER)).toBe('accepted'); + expect(statusOf(created.participants, ATTENDEE)).toBe('needs-action'); + }); + + // A store that answers a failure with nothing: the series stayed ended, + // told so to the attendees, and the save said it had worked. + it('puts the series back, telling the attendees again, when the store could not create the new one', async () => { + const occurrence = make(); + const deps = actions(series(version)); + deps.createEvent.mockResolvedValue(null); + + await expect(saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: true, userEmails: [ORGANIZER], + }, deps)).rejects.toThrow('rest of the series'); + + expect(deps.updateEvent).toHaveBeenCalledTimes(2); + // The whole map: the truncation took the series' only override, and a + // pointer into a series without overrides fails. + expect(deps.updateEvent).toHaveBeenLastCalledWith('b', { + recurrenceRules: series(version).recurrenceRules, + recurrenceOverrides: series(version).recurrenceOverrides, + }, { sendSchedulingMessages: true }); + }); + + it('puts the series back and passes on a refusal the store passes on', async () => { + const occurrence = make(); + const deps = actions(series(version)); + deps.createEvent.mockRejectedValue(new Error('An event cannot have more than 20 participants.')); + + await expect(saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: true, userEmails: [ORGANIZER], + }, deps)).rejects.toThrow('more than 20'); + + expect(deps.updateEvent).toHaveBeenLastCalledWith('b', expect.objectContaining({ + recurrenceRules: series(version).recurrenceRules, + }), { sendSchedulingMessages: true }); + }); + + it('ends an attendee\'s own copy quietly', async () => { + const occurrence = make(); + const deps = actions({ ...series(version), isOrigin: false }); + + await saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence, { participants: undefined }), editor: editorOn(occurrence), + sendScheduling: false, userEmails: [ATTENDEE], + }, deps); + + expect(deps.updateEvent).toHaveBeenCalledWith('b', truncation, { sendSchedulingMessages: false }); + expect(deps.createEvent.mock.calls[0][2]).toEqual({ sendSchedulingMessages: false }); + }); + + it('ends the series quietly when the user sends no invitations', async () => { + const occurrence = make(); + const deps = actions(series(version)); + + await saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence), editor: editorOn(occurrence), sendScheduling: false, userEmails: [ORGANIZER], + }, deps); + + expect(deps.updateEvent).toHaveBeenCalledWith('b', truncation, { sendSchedulingMessages: false }); + }); + + // The series in userc's calendar, shared with usera read & write: the + // split has to tell the attendees and ask them to the rest. + it('tells the attendees and asks them to the new series when the user splits the owner\'s series in a shared calendar', async () => { + const occurrence = make(); + const deps = actions({ ...series(version), isOrigin: true, isShared: true, accountId: 'f' }); + + await saveOccurrenceEdit('this_and_future', { + event: occurrence, updates: editorUpdates(occurrence, { title: 'Planning, room 2' }), editor: editorOn(occurrence), + sendScheduling: true, userEmails: [SHAREE], + }, deps); + + expect(deps.updateEvent).toHaveBeenCalledWith('b', truncation, { sendSchedulingMessages: true }); + const [created, , options] = deps.createEvent.mock.calls[0]; + expect(options).toEqual({ sendSchedulingMessages: true }); + expect(statusOf(created.participants, ORGANIZER)).toBe('accepted'); + expect(statusOf(created.participants, ATTENDEE)).toBe('needs-action'); + }); +}); + +describe('the new series from this and the following occurrences', () => { + it('starts at the occurrence and leaves the old series\' identity behind', () => { + const occurrence = serverOccurrence('1.0'); + const data = followingSeriesData(occurrence, series('1.0'), { title: 'Moved on', recurrenceId: occurrence.recurrenceId }, null); + + expect(data).toMatchObject({ title: 'Moved on', start: '2026-10-19T10:00:00', recurrenceRules: null, timeZone: 'Europe/Berlin' }); + expect(data).not.toHaveProperty('recurrenceId'); + // Without participants in the change there are none to ask. + expect(data).not.toHaveProperty('participants'); + }); + + it('leaves the calendar\'s default reminders to the switch', () => { + const occurrence = serverOccurrence('1.0'); + const defaults = { ...series('1.0'), useDefaultAlerts: true, alerts: { d1: { '@type': 'Alert' } } } as unknown as CalendarEvent; + + const data = followingSeriesData(occurrence, defaults, {}, defaults.recurrenceRules!); + + expect(data.useDefaultAlerts).toBe(true); + expect(data).not.toHaveProperty('alerts'); + }); +}); + +describe.each(occurrences)('saving one occurrence on Stalwart %s', (_label, _version, make) => { + it('hands the change to the store for that occurrence', async () => { + const occurrence = make(); + const deps = actions(series('1.0')); + const updates = editorUpdates(occurrence, { title: 'Planning, room 2' }); + + await saveOccurrenceEdit('this', { event: occurrence, updates, sendScheduling: true, userEmails: [ORGANIZER] }, deps); + + expect(deps.updateEvent).toHaveBeenCalledWith(occurrence.id, updates, { sendSchedulingMessages: true }); + expect(deps.getMasterEvent).not.toHaveBeenCalled(); + }); +}); + +describe.each(occurrences)('deleting part of a series from one occurrence on Stalwart %s', (_label, version, make) => { + it('deletes that occurrence alone through the store', async () => { + const occurrence = make(); + const deps = actions(series(version)); + + await deleteOccurrence('this', { event: occurrence, userEmails: [ORGANIZER] }, deps); + + expect(deps.deleteEvent).toHaveBeenCalledWith(occurrence.id); + }); + + it('ends the series before it, telling the attendees', async () => { + const occurrence = make(); + const deps = actions(series(version)); + + await deleteOccurrence('this_and_future', { event: occurrence, userEmails: [ORGANIZER] }, deps); + + expect(deps.updateEvent).toHaveBeenCalledWith('b', { + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-10-19T09:59:59' }], + 'recurrenceOverrides/2026-10-19T10:00:00': null, + }, { sendSchedulingMessages: true }); + expect(deps.deleteEvent).not.toHaveBeenCalled(); + }); + + it('ends an attendee\'s copy quietly', async () => { + const occurrence = make(); + const deps = actions({ ...series(version), isOrigin: false }); + + await deleteOccurrence('this_and_future', { event: occurrence, userEmails: [ATTENDEE] }, deps); + + expect(deps.updateEvent.mock.calls[0][2]).toEqual({ sendSchedulingMessages: false }); + }); + + it('deletes the series for every occurrence', async () => { + const deps = actions(series(version)); + + await deleteOccurrence('all', { event: make(), userEmails: [ORGANIZER] }, deps); + + expect(deps.deleteEvent).toHaveBeenCalledWith('b'); + }); +}); + +describe('ending an all-day series', () => { + it('ends it the day before the occurrence', async () => { + const master = { + ...series('0.16'), start: '2026-10-05T00:00:00', duration: 'P1D', timeZone: null, showWithoutTime: true, + utcStart: undefined, utcEnd: undefined, recurrenceOverrides: null, + } as unknown as CalendarEvent; + const occurrence = expandRecurringEvents([master], '2026-10-01T00:00:00', '2026-11-30T00:00:00') + .find((o) => o.recurrenceId === '2026-10-19T00:00:00')!; + const deps = actions(master); + + await deleteOccurrence('this_and_future', { event: occurrence, userEmails: [ORGANIZER] }, deps); + + expect(deps.updateEvent.mock.calls[0][1]).toEqual({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-10-18T23:59:59' }], + }); + }); +}); + +// An invitation to one occurrence alone: the server keeps it as an event of +// its own, with a recurrence id and no series to find. The store hands it +// back as its own "series". +describe('one occurrence the server keeps as its own event', () => { + const lone = () => ({ + ...serverOccurrence('1.0'), id: 'lone', baseEventId: undefined, recurrenceRules: null, recurrenceOverrides: null, + }) as unknown as CalendarEvent; + + it.each(['this', 'this_and_future', 'all'] as const)('is itself what changes, and what is deleted, for "%s"', async (scope) => { + const deps = actions(lone()); + + await saveOccurrenceEdit(scope, { event: lone(), updates: { title: 'Mine' }, userEmails: [ATTENDEE] }, deps); + await deleteOccurrence(scope, { event: lone(), userEmails: [ATTENDEE] }, deps); + + expect(deps.updateEvent.mock.calls).toEqual([['lone', { title: 'Mine' }, { sendSchedulingMessages: undefined }]]); + expect(deps.deleteEvent.mock.calls).toEqual([['lone']]); + expect(deps.createEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/calendar-participants.ts b/src/lib/calendar-participants.ts index 2661ef65..125885c6 100644 --- a/src/lib/calendar-participants.ts +++ b/src/lib/calendar-participants.ts @@ -511,23 +511,31 @@ export function seriesParticipantFields( } /** - * `participants` with every attendee but the organizer asked anew - * (needs-action). For a new series cut off an existing one ("This and - * following events"): Stalwart sends a new event only to attendees who have - * not answered, so an answer carried over would keep it from them. + * `event`'s participants with every attendee asked to answer again - back at + * needs-action - and the organizer's entries as they are. What its organizer + * does when an event moves (RFC 5546), and what a new event made from it + * needs: Stalwart sends a new event, and a new override of one occurrence, + * only to attendees without an answer (PARTSTAT absent or NEEDS-ACTION), and + * a change to an existing one carries each attendee's stored answer. Null + * when no attendee has answered. */ -export function askAttendeesAgain( - participants: Record, - organizerEmails: string[] = [], -): Record { - const organizers = new Set(organizerEmails.map((address) => address.trim().toLowerCase())); - const result: Record = {}; - for (const [id, p] of Object.entries(participants)) { +export function participantsAskedAgain( + event: Partial>, +): Record | null { + const organizers = getEventOrganizerEmails(event); + let asked = false; + const participants: Record = {}; + for (const [id, p] of Object.entries(event.participants ?? {})) { const address = getParticipantEmail(p).trim().toLowerCase(); - const organizer = !!p.roles?.owner || (!!address && organizers.has(address)); - result[id] = organizer ? p : { ...p, participationStatus: 'needs-action' }; + const organizer = !!p.roles?.owner || (!!address && organizers.includes(address)); + if (organizer || !p.participationStatus || p.participationStatus === 'needs-action') { + participants[id] = p; + continue; + } + participants[id] = { ...p, participationStatus: 'needs-action' }; + asked = true; } - return result; + return asked ? participants : null; } /** diff --git a/src/lib/recurrence-expansion.ts b/src/lib/recurrence-expansion.ts index 0c0cbefc..cdef6eba 100644 --- a/src/lib/recurrence-expansion.ts +++ b/src/lib/recurrence-expansion.ts @@ -66,6 +66,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: RecurrenceRule, 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: RecurrenceRule[] | 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(); } @@ -398,6 +429,7 @@ function generateDates( rangeStart: Date, rangeEnd: Date, includeStartDate = true, + maxOccurrences = 500, ): Date[] { const rule = addImplicitByX(rawRule, eventStart); const dates: Date[] = []; @@ -413,11 +445,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/src/lib/recurrence-instances.ts b/src/lib/recurrence-instances.ts index 2ff3e82a..2acd18e7 100644 --- a/src/lib/recurrence-instances.ts +++ b/src/lib/recurrence-instances.ts @@ -114,7 +114,8 @@ export function resolveOverrideKey( return instance.recurrenceId; } -function stableJson(value: unknown): string { +/** `value` as JSON with the members of every object in a fixed order: equal values, equal strings. */ +export function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; if (value && typeof value === 'object') { const entries = Object.entries(value as Record) diff --git a/src/lib/recurrence-overrides.ts b/src/lib/recurrence-overrides.ts index 67f9652f..d252d28b 100644 --- a/src/lib/recurrence-overrides.ts +++ b/src/lib/recurrence-overrides.ts @@ -1,13 +1,7 @@ -import { format, parseISO } from 'date-fns'; -import type { CalendarEvent, RecurrenceRule } from '../api/types'; -import { - askAttendeesAgain, - getEventOrganizerEmails, - seriesParticipantFields, - userOrganizes, - type Attendee, - type ParticipantSaveContext, -} from './calendar-participants'; +import type { CalendarEvent } from '../api/types'; + +// The scope edits themselves ("this", "this and following", "all") are in +// lib/recurrence-scope-edit.ts. /** Identity / whole-series keys that must never go into a single-occurrence override. */ export const RECURRENCE_OVERRIDE_IMMUTABLE_KEYS = [ @@ -31,198 +25,3 @@ export function isRecurringSeriesMember( ): boolean { return !!event.recurrenceRules?.length || !!event.recurrenceId; } - -/** The original slot of an occurrence as a local wall-clock Date. */ -export function occurrenceSlotDate( - event: Pick, -): Date { - // recurrenceId is either "yyyy-MM-dd" (all-day) or the occurrence instant - // as UTC ISO; parseISO treats a date-only value as local midnight, which is - // what the RRULE `until` below needs. `new Date("yyyy-MM-dd")` would read - // it as UTC and shift the day in any non-UTC zone. - return parseISO(event.recurrenceId || event.start); -} - -/** - * "This and following": end the master's rules one second before the chosen - * occurrence. `count` is dropped so the truncated rule is bounded by `until` - * alone (a rule can't carry both). Mirrors webmail's truncateRecurrenceAtEvent. - */ -export function truncateRecurrenceRules( - rules: RecurrenceRule[] | undefined | null, - occurrence: Pick, -): RecurrenceRule[] { - const untilDate = occurrenceSlotDate(occurrence); - untilDate.setSeconds(untilDate.getSeconds() - 1); - const until = format(untilDate, "yyyy-MM-dd'T'HH:mm:ss"); - return (rules || []).map((rule) => { - const { count: _count, ...rest } = rule; - return { ...rest, until }; - }); -} - -/** What an editor save changed about when the event happens. */ -export interface ScheduleChanges { - /** Its start, end or all-day. */ - timesMoved?: boolean; - /** Its recurrence rule. */ - ruleChanged?: boolean; -} - -/** - * Updates for the "All events" scope. The editor seeds its date fields from - * the tapped occurrence, so an unchanged `start` must not be written to the - * master (that would move the whole series onto the occurrence's date and - * make the earlier occurrences vanish). When the user did move the occurrence, - * the same delta is applied to the master's start instead. With - * `changes.timesMoved` false none of the occurrence's times reach the master - * - a moved or resized occurrence would move or resize every one - and with - * `changes.ruleChanged` false neither does the rule the editor wrote back. - */ -export function buildAllScopeUpdates( - updates: Partial, - occurrence: Pick, - master: Pick, - changes: ScheduleChanges = {}, -): Partial { - const out: Partial = { ...updates }; - delete (out as Record).recurrenceId; - if (changes.ruleChanged === false) delete out.recurrenceRules; - if (changes.timesMoved === false) { - delete out.start; - delete out.duration; - delete out.timeZone; - delete out.showWithoutTime; - return out; - } - if (!out.start) return out; - if (!occurrence.recurrenceId) return out; // editing the master itself - const nextStart = parseISO(out.start); - const occurrenceStart = parseISO(occurrence.start); - const masterStart = parseISO(master.start); - if ( - isNaN(nextStart.getTime()) || - isNaN(occurrenceStart.getTime()) || - isNaN(masterStart.getTime()) - ) { - delete out.start; - return out; - } - const delta = nextStart.getTime() - occurrenceStart.getTime(); - if (delta === 0) { - delete out.start; - return out; - } - const shifted = new Date(masterStart.getTime() + delta); - out.start = format(shifted, "yyyy-MM-dd'T'HH:mm:ss"); - return out; -} - -/** - * The new series created for "This and following": the master's series-level - * fields, the original (untruncated) rules, the user's edits on top, and the - * occurrence's slot as the start unless the user moved it. - */ -export function buildFutureSeriesData( - master: CalendarEvent, - originalRules: RecurrenceRule[] | null, - occurrence: Pick, - updates: Partial, -): Partial { - const occurrenceStart = occurrence.start; - const data: Partial = { - title: master.title, - description: master.description, - duration: master.duration, - timeZone: master.timeZone, - calendarIds: { ...master.calendarIds }, - status: master.status, - freeBusyStatus: master.freeBusyStatus, - showWithoutTime: master.showWithoutTime, - participants: master.participants, - organizerCalendarAddress: master.organizerCalendarAddress, - alerts: master.alerts, - useDefaultAlerts: master.useDefaultAlerts, - locations: master.locations, - virtualLocations: master.virtualLocations, - recurrenceRules: originalRules ?? undefined, - ...updates, - start: updates.start || occurrenceStart, - }; - const raw = data as Record; - delete raw.id; - delete raw.uid; - delete raw.recurrenceId; - delete raw.recurrenceOverrides; - delete raw.excludedRecurrenceRules; - delete raw.utcStart; - delete raw.utcEnd; - delete raw.originalId; - delete raw.baseEventId; - delete raw.recurrenceIdTimeZone; - delete raw.originalCalendarIds; - delete raw.accountId; - delete raw.isShared; - for (const key of Object.keys(raw)) { - if (raw[key] === undefined) delete raw[key]; - } - return data; -} - -/** An editor save of one occurrence, for the scope it is applied to. */ -export interface SeriesEdit extends ScheduleChanges { - /** The attendee rows the user left. */ - attendees: Attendee[]; -} - -/** - * The update of a whole series ("All events") from an editor opened on one of - * its occurrences: its times only when the user moved the occurrence (and - * then by the same delta), its rule only when the user changed it, and its - * participants from the series' own list with the attendees the user added - * or removed (seriesParticipantFields) - never the occurrence's list, whose - * answers are that day's. A series the user organizes and moves asks its - * attendees again. - */ -export function buildAllScopeEdit( - updates: Partial, - occurrence: CalendarEvent, - master: CalendarEvent, - edit: SeriesEdit, - context: ParticipantSaveContext, -): Partial { - const out = buildAllScopeUpdates(updates, occurrence, master, edit); - delete out.participants; - delete out.organizerCalendarAddress; - delete out.replyTo; - return { - ...out, - ...seriesParticipantFields(master, occurrence, edit.attendees, { - ...context, - rescheduled: !!edit.timesMoved || !!edit.ruleChanged, - }), - }; -} - -/** - * The new series of "This and following events", as buildFutureSeriesData - * makes it. It is a new event (a new UID) to its attendees, and Stalwart - * sends a new event only to attendees who have not answered: when the user - * organizes it, everyone but the organizer is asked anew. Whether they do is - * the old series' to say: the new one carries no `isOrigin` yet, and the - * owner's meeting in a calendar shared with the user is theirs to change - * (userOrganizes). - */ -export function buildFutureSeriesEdit( - master: CalendarEvent, - originalRules: RecurrenceRule[] | null, - occurrence: Pick, - updates: Partial, - userEmails: string[], -): Partial { - const data = buildFutureSeriesData(master, originalRules, occurrence, updates); - if (data.participants && userOrganizes({ ...data, isOrigin: master.isOrigin }, userEmails)) { - data.participants = askAttendeesAgain(data.participants, getEventOrganizerEmails(data)); - } - return data; -} diff --git a/src/lib/recurrence-scope-edit.ts b/src/lib/recurrence-scope-edit.ts new file mode 100644 index 00000000..950322fd --- /dev/null +++ b/src/lib/recurrence-scope-edit.ts @@ -0,0 +1,806 @@ +/** + * Saving an event editor change made on one occurrence of a series for the + * part of the series the user picks in the recurrence scope dialog - that + * occurrence, it and the ones after it, or all of them - and deleting that + * part. Port of the webmail's lib/recurrence-scope-edit.ts, on the calendar + * store's actions. + */ + +import { differenceInCalendarDays } from 'date-fns'; +import type { CalendarEvent, RecurrenceRule } from '../api/types'; +import type { RecurrenceEditScope } from '../components/calendar/RecurrenceScopeDialog'; +import { getEventStartDate, getPrimaryCalendarId } from './calendar-utils'; +import { + participantsAskedAgain, + seriesParticipantFields, + userOrganizes, + type Attendee, + type ParticipantSaveContext, +} from './calendar-participants'; +import { editorTimesChanged, type EditorTimes } from './event-editor-times'; +import { + buildOccurrencePatch, + isServerRecurrenceInstance, + parseDurationSeconds, + resolveOverrideKey, + stableJson, +} from './recurrence-instances'; +import { occurrencesBefore, seriesOccursAt } from './recurrence-expansion'; +import { recurrenceRulesEqual } from './recurrence'; +import { + formatWallClock, + fromDisplayDate, + getEffectiveTimeZone, + isValidTimeZone, + localDateTimeToInstant, +} from './calendar-timezone'; + +/** What the event editor had when it saved, besides the event data it wrote. */ +export interface SeriesEdit { + /** The attendee rows the user left; the organizer is not among them. */ + attendees: Attendee[]; + /** When the event happens in the editor: display dates, the last day of an all-day event. */ + when: EditorTimes; + /** The user changed the recurrence rule: a series then happens on other days. */ + ruleChanged?: boolean; +} + +const WHEN_KEYS = ['start', 'duration', 'timeZone', 'showWithoutTime'] as const; +type SeriesWhen = Partial>; + +/** A display date's wall clock to the minute (the editor shows no seconds), as milliseconds. */ +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 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); +} + +/** A LocalDateTime read as the wall clock in `fromZone`, as the wall clock in `toZone` at the same instant. */ +function moveLocalDateTime(value: string, fromZone: string, toZone: string): string { + if (fromZone === toZone || !isValidTimeZone(toZone)) return value; + const instant = localDateTimeToInstant(value, fromZone); + return instant ? formatWallClock(instant, toZone) : value; +} + +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. + */ +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; +} + +/** `date`, a display date in the calendar's zone, as the wall clock of `timeZone` to the minute, in milliseconds. */ +function zonedMinutesMs(date: Date, timeZone: string): number { + const wall = wallClockMs(formatWallClock(fromDisplayDate(date), timeZone)) ?? 0; + return Math.floor(wall / 60_000) * 60_000; +} + +/** + * How far the editor moved an occurrence from `from` to `to` (display dates) + * on the series' own clock: its zone's wall clock when it has one. Measured + * on the calendar's clock, a move across a week in which only one of the two + * zones changed to or from daylight saving time moved the series by an hour + * more or less than the occurrence: from Saturday 10:00 in New York to + * Monday 11:00 there (16:00 on both days for a user in Berlin) made it + * Mondays at 10:00. + */ +function seriesClockDelta(from: Date, to: Date, series: CalendarEvent): number { + const zone = !series.showWithoutTime && series.timeZone && isValidTimeZone(series.timeZone) ? series.timeZone : null; + return zone + ? zonedMinutesMs(to, zone) - zonedMinutesMs(from, zone) + : wallClockMinutesMs(to) - wallClockMinutesMs(from); +} + +/** + * When the series happens after the editor moved one of its occurrences: + * its own start moved by as much as the occurrence was, on its own date and + * in its own zone (seriesClockDelta); the length only when the editor + * changed it. Nothing when the occurrence did not move. + */ +function seriesWhen( + occurrence: CalendarEvent, + master: CalendarEvent, + updates: Partial, + when: EditorTimes, +): SeriesWhen { + if (!editorTimesChanged(when, occurrence)) return {}; + const result: SeriesWhen = {}; + const allDayChanged = !!master.showWithoutTime !== when.allDay; + const from = getEventStartDate(occurrence); + + if (when.allDay) { + // The series' first day as the calendar shows it, moved by the days the + // occurrence moved. + const first = !master.showWithoutTime && master.timeZone + ? moveLocalDateTime(master.start, master.timeZone, getEffectiveTimeZone()) + : master.start; + const days = differenceInCalendarDays(when.start, from); + if (days !== 0 || allDayChanged) { + result.start = `${shiftLocalDateTime(`${first.slice(0, 10)}T00:00:00`, days * 86_400_000).slice(0, 10)}T00:00:00`; + } + } else { + const delta = seriesClockDelta(from, when.start, master); + if (delta !== 0 || allDayChanged) result.start = shiftLocalDateTime(master.start, delta); + } + + const seconds = parseDurationSeconds(occurrence.duration ?? master.duration); + if (allDayChanged || parseDurationSeconds(updates.duration) !== seconds) { + result.duration = updates.duration; + } + if (allDayChanged) { + result.showWithoutTime = when.allDay; + result.timeZone = updates.timeZone ?? null; + } + return result; +} + +/** The editor's fields a save changes on its own: when (seriesWhen), who (seriesParticipantFields), which occurrence. */ +const NOT_EDITOR_FIELDS = new Set([...WHEN_KEYS, 'recurrenceId', 'participants', 'organizerCalendarAddress', 'replyTo']); + +/** The first location's name, or virtual location's URI, as the editor shows it. */ +const firstOf = (map: Record | null | undefined, field: 'name' | 'uri') => + (Object.values(map ?? {})[0]?.[field] ?? '').trim(); + +/** + * The fields of `updates` - what the editor wrote for `occurrence` - that + * the user changed there, compared the way the editor shows them. The + * editor writes every field it shows, and on an occurrence with an override + * of its own those are the occurrence's: its title, copied when it got the + * override, or a room of its own. Written to the whole series, a new room + * on such an occurrence also gave the series back that occurrence's old + * title. + */ +export function changedEditorFields( + occurrence: CalendarEvent, + updates: Partial, + editor?: Pick, +): Partial { + const ruleChanged = editor ? !!editor.ruleChanged : !recurrenceRulesEqual(occurrence.recurrenceRules, updates.recurrenceRules); + const changed: Record = {}; + for (const [key, value] of Object.entries(updates)) { + if (NOT_EDITOR_FIELDS.has(key)) continue; + let differs: boolean; + switch (key) { + case 'title': + differs = (value ?? '') !== (occurrence.title ?? ''); + break; + case 'description': + differs = String(value ?? '').trim() !== (occurrence.description ?? '').trim(); + break; + case 'locations': + differs = firstOf(value as CalendarEvent['locations'], 'name') !== firstOf(occurrence.locations, 'name'); + break; + case 'virtualLocations': + differs = firstOf(value as CalendarEvent['virtualLocations'], 'uri') !== firstOf(occurrence.virtualLocations, 'uri'); + break; + case 'calendarIds': + differs = stableJson(Object.keys(value ?? {}).sort()) !== stableJson(Object.keys(occurrence.calendarIds ?? {}).sort()); + break; + case 'recurrenceRules': + case 'recurrenceOverrides': + case 'excludedRecurrenceRules': + differs = ruleChanged; + break; + default: + differs = stableJson(value) !== stableJson((occurrence as unknown as Record)[key]); + } + if (differs) changed[key] = value; + } + return changed as Partial; +} + +/** + * `entry`, an override of `series`, with the editor's `changes` to the + * series, where it holds a value of the field: the override of the + * occurrence the editor was opened on takes them all - they were made on it + * - any other one where it only held the series' old value (0.16 keeps a + * copy of the title, place and details in every override the app writes: + * without this, a renamed series kept its old name on those occurrences). A + * value an override has of its own stays; one it does not hold comes from + * the series. + */ +function withSeriesChanges(entry: OverrideEntry, changes: Partial, series: CalendarEvent, opened: boolean): OverrideEntry { + const result = { ...entry }; + // Only what one occurrence may carry (buildOccurrencePatch): not the + // rule, the calendar or the default alerts switch. + for (const [key, value] of Object.entries(buildOccurrencePatch(changes))) { + // 1.0 returns an override as a PatchObject: a field of its own can be + // pointers into it (`locations/loc1/name`), which would keep the old + // value on the occurrence the change was made on. + const pointers = Object.keys(result).filter((name) => name.startsWith(`${key}/`)); + if (opened && (key in result || pointers.length > 0)) { + for (const name of pointers) delete result[name]; + result[key] = value; + } else if (!opened && key in result && stableJson(result[key]) === stableJson((series as unknown as Record)[key])) { + result[key] = value; + } + } + return result; +} + +/** + * Whether `entry`, the override under `key`, changes nothing about its + * occurrence in `series`: each value it holds is the series' - its start the + * occurrence's own. The server's bookkeeping in it does not count. + */ +function changesNothing(entry: OverrideEntry, key: string, series: Partial): boolean { + return !entry.excluded && Object.entries(entry).every(([name, value]) => { + if (name === 'sequence' || name === 'updated') return true; + if (name === 'start') return typeof value === 'string' && wallClockMs(value) === wallClockMs(key); + return stableJson(value) === stableJson((series as unknown as Record)[name]); + }); +} + +/** + * 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. + * What the user changed in the editor goes into the overrides as well + * (withSeriesChanges). + */ +function seriesOverrides( + occurrence: CalendarEvent, + master: CalendarEvent, + series: Partial, + options: { ruleChanged: boolean; askAgain: boolean; changes: Partial }, +): OverrideMap | null | undefined { + const overrides = master.recurrenceOverrides; + if (!overrides || Object.keys(overrides).length === 0) return undefined; + const resized = series.duration !== undefined; + const moves = series.start !== undefined || series.showWithoutTime !== undefined; + const changes = Object.keys(buildOccurrencePatch(options.changes)).length > 0; + if (!moves && !resized && !options.ruleChanged && !changes) 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 (changes) entry = withSeriesChanges(entry, options.changes, master, key === opened); + if (seriesOccursAt(to.start, to.recurrenceRules, target) && changesNothing(entry, target, { ...master, ...series })) continue; + } + result[target] = entry as OverrideMap[string]; + } + // Nothing moved and no override took a change: they stay as they are. + if (stableJson(result) === stableJson(withoutUpdated(overrides))) return undefined; + return Object.keys(result).length > 0 ? result : null; +} + +/** Overrides without the server's `updated` stamps, as a rewrite sends them. */ +function withoutUpdated(overrides: OverrideMap): OverrideMap { + return Object.fromEntries(Object.entries(overrides).map(([key, override]) => { + const { updated: _updated, ...rest } = override as OverrideEntry; + return [key, rest]; + })) as OverrideMap; +} + +/** + * The editor's `updates`, made on `occurrence`, as a change to its whole + * series `master`. + * + * The editor shows one occurrence, so the dates and participants it writes + * are that occurrence's. Written to the series as they are, the series + * started on the occurrence's day - the occurrences before it gone, a + * counted series running longer - and every occurrence got that one + * occurrence's attendees and answers. The series keeps its own instead: + * - its details, but for those the user changed (changedEditorFields); + * - when it happens, unless the occurrence moved; then the series moves by + * 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 (seriesParticipantFields); + * - its overrides, each with its occurrence (seriesOverrides). + */ +export function seriesUpdatesFromOccurrence( + occurrence: CalendarEvent, + master: CalendarEvent, + updates: Partial, + editor: SeriesEdit, + context: Omit, +): Partial { + const changes = changedEditorFields(occurrence, updates, editor); + const series: Partial = { ...changes }; + const when = seriesWhen(occurrence, master, updates, editor.when); + Object.assign(series, when); + const rescheduled = Object.keys(when).length > 0 || !!editor.ruleChanged; + const participants = seriesParticipantFields(master, occurrence, editor.attendees, { ...context, rescheduled }); + Object.assign(series, participants); + const overrides = seriesOverrides(occurrence, master, series, { + ruleChanged: !!editor.ruleChanged, + askAgain: rescheduled && !!participants.participants, + changes, + }); + if (overrides !== undefined) series.recurrenceOverrides = overrides; + return series; +} + +/** The calendar store's scheduling switch for one write: true sends the attendees messages, false is quiet. */ +interface SchedulingOptions { + sendSchedulingMessages?: boolean; +} + +/** What saving an occurrence edit works with: the calendar store's actions. */ +export interface OccurrenceEditActions { + updateEvent: ( + id: string, + changes: Partial | Record, + options?: SchedulingOptions, + ) => Promise; + /** The created event; a store that answers a failure with nothing counts as failed. */ + createEvent: ( + event: Partial, + calendarId: string, + options?: SchedulingOptions, + ) => Promise; + /** The base event of a series, from one of its occurrences. */ + getMasterEvent: (occurrence: CalendarEvent) => Promise; +} + +export interface OccurrenceEdit { + /** The occurrence the editor was opened on. */ + event: CalendarEvent; + /** What the editor wrote for it. */ + updates: Partial; + /** What the editor had. Without it the updates go to the series as they are. */ + editor?: SeriesEdit; + /** The calendar (store id) the editor saves to: where the rest of a split series goes. */ + calendarId?: string; + /** The editor's "Send invitations": the organizer's messages to the attendees. */ + sendScheduling?: boolean; + /** The user's calendar addresses; the first organizes new invitations. */ + userEmails: string[]; + /** A name for an address the event data carries none for. */ + resolveName?: (email: string) => string | undefined; +} + +/** + * An occurrence the server keeps as an event of its own - an invitation to + * that occurrence alone - with no series to find: whatever the scope, it is + * the one thing there is to change or delete. + */ +function isLoneOccurrence(event: CalendarEvent): boolean { + return !!event.recurrenceId && !event.recurrenceRules?.length && !isServerRecurrenceInstance(event); +} + +/** + * The series `event` belongs to, or null. The store hands back the stored + * event for an id it cannot place as a series; only one with a rule is one. + */ +async function seriesOf(event: CalendarEvent, actions: Pick): Promise { + const master = await actions.getMasterEvent(event); + return master && (master.recurrenceRules?.length ?? 0) > 0 ? master : null; +} + +/** 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); anyone else ends their own copy quietly. + * The overrides from `occurrence` on go as well, in the same update: 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<{ restore: Partial; announced: boolean; removed: OverrideMap }> { + const originalRules: RecurrenceRule[] | null = master.recurrenceRules + ? JSON.parse(JSON.stringify(master.recurrenceRules)) + : null; + const splitKey = splitKeyOf(occurrence, master); + const until = untilBefore(splitKey); + const truncatedRules = (master.recurrenceRules || []).map((rule) => ({ + ...rule, + until, + count: null, + })); + const patch: Record = { recurrenceRules: truncatedRules }; + const restore: Partial = { 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; + } + // Put back as the whole map: a pointer into it fails while the series has + // no overrides at all, which the truncation may just have left it with. + if (Object.keys(removed).length > 0) restore.recurrenceOverrides = withoutUpdated(master.recurrenceOverrides!); + const announced = !!scheduling.sendScheduling && userOrganizes(master, scheduling.userEmails); + await actions.updateEvent(master.id, patch, { sendSchedulingMessages: announced }); + return { restore, announced, removed }; +} + +/** The UNTIL that ends a series just before its occurrence at `splitKey`: a second before it, on the series' own clock. */ +function untilBefore(splitKey: string): string { + return shiftLocalDateTime(splitKey, -1000); +} + +/** + * The 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 from the split on (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. They take what the user changed in the editor + * as `series`, the old series, took it (withSeriesChanges). The occurrence + * the editor was opened on is the new series' first: moved on its own, it + * stays off the new series' rhythm by as much as before, plus the editor's + * move. + */ +function followingOverrides( + overrides: OverrideMap | undefined, + splitKey: string, + from: SeriesFrame, + to: SeriesFrame & { participants?: CalendarEvent['participants']; duration?: string }, + series: CalendarEvent, + changes: Partial, + newSeries: Partial, +): OverrideMap | undefined { + 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 result: OverrideMap = {}; + for (const [key, override] of Object.entries(overrides)) { + const target = overrideKeyIn(key, from, to, splitKey); + if (!target) continue; + const opened = key === splitKey; + const { updated: _updated, ...rest } = override as OverrideEntry; + let 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 (opened) { + entry.start = movedLike(entry.start, splitKey, to.start); + } 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 (!turned && resized && typeof entry.duration === 'string' + && (opened || parseDurationSeconds(entry.duration) === parseDurationSeconds(series.duration))) { + entry.duration = to.duration; + } + entry = withSeriesChanges(entry, changes, series, opened); + if (seriesOccursAt(to.start, to.recurrenceRules, target) && changesNothing(entry, target, newSeries)) continue; + } + result[target] = entry as OverrideMap[string]; + } + return Object.keys(result).length > 0 ? result : undefined; +} + +/** What of the old series a new one made from it keeps: its details, where and when, and its reminders. */ +const FOLLOWING_SERIES_KEYS = [ + 'title', 'description', 'descriptionContentType', 'locations', 'virtualLocations', 'links', 'keywords', + 'categories', 'color', 'locale', 'status', 'freeBusyStatus', 'privacy', 'calendarIds', 'useDefaultAlerts', + 'duration', 'timeZone', 'showWithoutTime', +] as const; + +/** + * The new series "this and the following occurrences" becomes: the old + * series with what the user changed in the editor, 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. + * + * 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 + * as the editor moved the occurrence (seriesWhen). It started at the + * occurrence's own time, in the calendar's zone: a Monday meeting in New York + * whose one occurrence had moved to Tuesday became a Tuesday series in + * Berlin from there on. What the editor showed of that occurrence alone - + * its own time, a title of its own - stays with it (followingOverrides). + * Without the editor's state the updates are the new series as they are. + */ +export function followingSeriesData( + occurrence: CalendarEvent, + master: CalendarEvent, + updates: Partial, + originalRules: CalendarEvent['recurrenceRules'], + options: { editor?: SeriesEdit; overrides?: OverrideMap } = {}, +): Partial { + const { editor } = options; + 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 = editor + ? !!editor.ruleChanged + : updates.recurrenceRules !== undefined && !recurrenceRulesEqual(updates.recurrenceRules, originalRules); + const changes = editor ? changedEditorFields(occurrence, updates, editor) : updates; + const { recurrenceRules: editorRules, ...changed } = changes; + + const data: Partial = {}; + const source = master as unknown as Record; + for (const key of FOLLOWING_SERIES_KEYS) { + if (source[key] !== undefined) (data as Record)[key] = source[key]; + } + data.calendarIds = { ...master.calendarIds }; + // While the calendar's default reminders are on, 1.0 lists them as the + // series' own: they come with the switch, not as copies. + if (!master.useDefaultAlerts && master.alerts) data.alerts = master.alerts; + Object.assign(data, changed); + data.recurrenceRules = ruleChanged && editorRules !== undefined ? editorRules : remainingRules(originalRules, master.start, splitKey); + if (editor) { + Object.assign(data, seriesWhen(occurrence, { ...master, start: splitKey }, updates, editor.when)); + data.start ??= splitKey; + for (const key of ['participants', 'organizerCalendarAddress'] as const) { + if (updates[key] !== undefined) (data as Record)[key] = updates[key]; + } + } else { + data.start = updates.start || occurrence.recurrenceId || occurrence.start; + } + const raw = data as Record; + delete raw.id; + delete raw.uid; + delete raw.recurrenceId; + delete raw.recurrenceOverrides; + if (data.participants) { + data.participants = participantsAskedAgain({ + participants: data.participants, + 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 ?? undefined, duration: data.duration, + }, + master, editor ? changed : {}, data, + ); + if (overrides) data.recurrenceOverrides = overrides; + return data; +} + +/** + * Save the editor's change to one occurrence for `scope`. Resolves to false + * when the series the occurrence belongs to cannot be found. + */ +export async function saveOccurrenceEdit( + scope: RecurrenceEditScope, + edit: OccurrenceEdit, + actions: OccurrenceEditActions, +): Promise { + const { updateEvent, createEvent } = actions; + const { event, updates, sendScheduling } = edit; + const scheduling = { sendSchedulingMessages: sendScheduling }; + if (scope === 'this' || isLoneOccurrence(event)) { + // The store keeps the change on this occurrence: through its own + // (synthetic) id, or as a recurrence override on the event it was + // expanded from. + await updateEvent(event.id, updates, scheduling); + return true; + } + const master = await seriesOf(event, actions); + if (!master) return false; + if (scope === 'this_and_future') { + 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, + }); + // The calendar is the one the editor saves to; the store routes it. + delete newEventData.calendarIds; + const calendarId = edit.calendarId || getPrimaryCalendarId(master) || ''; + try { + const created = await createEvent(newEventData, calendarId, scheduling); + if (!created) throw new Error('Failed to create the rest of the series'); + } catch (createError) { + // Put the series back as it was, telling whoever the truncation told. + try { + await updateEvent(master.id, restore, { sendSchedulingMessages: announced }); + } catch { + // The rollback failing is reported through the original error. + } + throw createError; + } + return true; + } + let seriesUpdates: Partial; + if (edit.editor) { + seriesUpdates = seriesUpdatesFromOccurrence(event, master, updates, edit.editor, { + userEmail: edit.userEmails[0], + userEmails: edit.userEmails, + resolveName: edit.resolveName, + }); + } else { + seriesUpdates = { ...updates }; + delete seriesUpdates.recurrenceId; + } + await updateEvent(master.id, seriesUpdates, scheduling); + return true; +} + +/** What deleting part of a series works with: the calendar store's actions. */ +export interface OccurrenceDeleteActions extends Pick { + deleteEvent: (id: string) => Promise; +} + +/** + * Delete the part of a series `scope` names, from `event`, the occurrence + * the user picked it on. Resolves to false when the series cannot be found. + * The store tells the attendees of what it destroys itself; ending a series + * tells them when the user organizes it. + */ +export async function deleteOccurrence( + scope: RecurrenceEditScope, + deletion: { event: CalendarEvent; userEmails: string[] }, + actions: OccurrenceDeleteActions, +): Promise { + const { event } = deletion; + if (scope === 'this' || isLoneOccurrence(event)) { + // The store destroys a server occurrence, or excludes one the device + // expanded on its base event. + await actions.deleteEvent(event.id); + return true; + } + const master = await seriesOf(event, actions); + if (!master) return false; + if (scope === 'this_and_future') { + await truncateSeries(master, event, actions, { sendScheduling: true, userEmails: deletion.userEmails }); + return true; + } + await actions.deleteEvent(master.id); + return true; +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 9fda8733..6e83aaa5 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -91,13 +91,8 @@ import { type TimeFormat, } from '../lib/calendar-utils'; import { buildReplyTo } from '../lib/calendar-invitation'; -import { - buildAllScopeEdit, - buildFutureSeriesEdit, - isRecurringSeriesMember, - truncateRecurrenceRules, - type SeriesEdit, -} from '../lib/recurrence-overrides'; +import { isRecurringSeriesMember } from '../lib/recurrence-overrides'; +import { deleteOccurrence, saveOccurrenceEdit, type SeriesEdit } from '../lib/recurrence-scope-edit'; import { participantLimitMessage } from '../lib/calendar-participants'; import { maxParticipantsPerEvent } from '../api/calendar'; import { useContactNameResolver } from '../lib/contact-name-resolver'; @@ -110,7 +105,7 @@ import { startCalendarNotificationSync } from '../lib/calendar-notifications'; import { useCalendarReminderOpen } from '../lib/calendar-reminder-open'; import { shareEventICS } from '../lib/calendar-ics-export'; import * as Clipboard from 'expo-clipboard'; -import type { Calendar, CalendarEvent, RecurrenceRule } from '../api/types'; +import type { Calendar, CalendarEvent } from '../api/types'; type ViewMode = 'month' | 'week' | 'day' | 'agenda'; type PendingAction = @@ -603,24 +598,6 @@ export default function CalendarScreen() { } }, [deleteEvent, isReadOnlyEvent, reportError]); - // "This and following": end the master at the occurrence and hand back the - // master plus its untouched rules so the caller can start a new series (or - // roll back). Port of webmail's truncateRecurrenceAtEvent. - const truncateRecurrenceAtEvent = React.useCallback( - async (event: CalendarEvent) => { - const master = await getMasterEvent(event); - if (!master) return null; - const originalRules = master.recurrenceRules - ? (JSON.parse(JSON.stringify(master.recurrenceRules)) as RecurrenceRule[]) - : null; - await updateEvent(master.id, { - recurrenceRules: truncateRecurrenceRules(master.recurrenceRules, event), - }); - return { master, originalRules }; - }, - [getMasterEvent, updateEvent], - ); - // Send an answer and show it in the open detail sheet. 'occurrence' // answers just that occurrence of a series. const submitRsvp = React.useCallback( @@ -664,74 +641,22 @@ export default function CalendarScreen() { return; } try { - if (action.kind === 'edit') { - const { updates } = action; - const opts = { sendSchedulingMessages: action.sendScheduling }; - const participantContext = { - userEmail: currentUserEmails[0], + // lib/recurrence-scope-edit: which occurrences change, and how the + // series, its overrides and its attendees follow. + const done = action.kind === 'edit' + ? await saveOccurrenceEdit(scope, { + event, + updates: action.updates, + editor: action.edit, + calendarId: action.calendarId, + sendScheduling: action.sendScheduling, userEmails: currentUserEmails, resolveName: resolveContactName, - }; - switch (scope) { - case 'this': { - // The store keeps the change on this occurrence: through its - // own (synthetic) id, or as a recurrence override on the event - // it was expanded from. - await updateEvent(event.id, updates, opts); - break; - } - case 'this_and_future': { - const result = await truncateRecurrenceAtEvent(event); - if (!result) throw new Error('Master event not found'); - const { master, originalRules } = result; - const newEventData = buildFutureSeriesEdit(master, originalRules, event, updates, currentUserEmails); - delete newEventData.calendarIds; - const targetCalendarId = - action.calendarId || getPrimaryCalendarId(master) || ''; - try { - await createEvent(newEventData, targetCalendarId, opts); - } catch (createError) { - // Roll back the truncation so the series isn't left cut short. - try { - await updateEvent(master.id, { recurrenceRules: originalRules ?? [] }); - } catch { - // The rollback failing is reported through the original error. - } - throw createError; - } - break; - } - case 'all': { - const master = await getMasterEvent(event); - if (!master) throw new Error('Master event not found'); - const changes = action.edit - ? buildAllScopeEdit(updates, event, master, action.edit, participantContext) - : updates; - await updateEvent(master.id, changes, opts); - break; - } - } - } else { - switch (scope) { - case 'this': { - // The store destroys a server occurrence, or excludes one the - // device expanded on its base event. - await deleteEvent(event.id); - break; - } - case 'this_and_future': { - const result = await truncateRecurrenceAtEvent(event); - if (!result) throw new Error('Master event not found'); - break; - } - case 'all': { - const master = await getMasterEvent(event); - if (!master) throw new Error('Master event not found'); - await deleteEvent(master.id); - break; - } - } - } + }, { updateEvent, createEvent, getMasterEvent }) + : await deleteOccurrence(scope, { event, userEmails: currentUserEmails }, { + updateEvent, deleteEvent, getMasterEvent, + }); + if (!done) throw new Error('Master event not found'); } catch (err) { Alert.alert( t('calendar.notifications.event_error', 'Something went wrong'), @@ -745,7 +670,7 @@ export default function CalendarScreen() { } }, [ - pendingAction, updateEvent, deleteEvent, createEvent, getMasterEvent, truncateRecurrenceAtEvent, refresh, + pendingAction, updateEvent, deleteEvent, createEvent, getMasterEvent, refresh, submitRsvp, t, currentUserEmails, resolveContactName, ], ); diff --git a/src/stores/__tests__/calendar-store-series-edits.test.ts b/src/stores/__tests__/calendar-store-series-edits.test.ts new file mode 100644 index 00000000..93a2e2dd --- /dev/null +++ b/src/stores/__tests__/calendar-store-series-edits.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// The scope edits (lib/recurrence-scope-edit) on the real calendar store, +// with the JMAP calls mocked: what reaches CalendarEvent/set when "this and +// the following occurrences" cannot create the rest of the series. + +vi.hoisted(() => { + process.env.TZ = 'Europe/Berlin'; +}); + +vi.mock('../../api/calendar', () => ({ + getCalendars: vi.fn(), + queryEvents: vi.fn(async () => []), + getEvents: vi.fn(async () => []), + getEventIdentities: vi.fn(async () => []), + scanCalendarObjects: vi.fn(async () => []), + isCalendarAccessDenied: vi.fn(() => false), + noteCalendarAccessError: vi.fn(() => false), + resetCalendarAccessDenied: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(async () => undefined), + deleteEvents: vi.fn(async () => undefined), + batchCreateEvents: vi.fn(), + setDefaultCalendar: vi.fn(), + rsvpEvent: vi.fn(), + supportsSyntheticCalendarIds: vi.fn(async () => false), + queryExpandedEvents: vi.fn(async () => []), + hydrateExpandedOccurrences: vi.fn(async (events: unknown[]) => events), + resetSyntheticIdSupport: vi.fn(), + resetTimeZoneChecks: vi.fn(), +})); + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { accountId: 'acc-1', isConnected: true }, +})); + +import * as calendarApi from '../../api/calendar'; +import type { CalendarEvent } from '../../api/types'; +import { useCalendarStore } from '../calendar-store'; +import { saveOccurrenceEdit } from '../../lib/recurrence-scope-edit'; +import { editorTimesFromEvent } from '../../lib/event-editor-times'; + +const mockUpdate = vi.mocked(calendarApi.updateEvent); +const mockCreate = vi.mocked(calendarApi.createEvent); +const mockGetEvents = vi.mocked(calendarApi.getEvents); + +const ORGANIZER = 'userc@example.org'; +const participants = { + org: { calendarAddress: `mailto:${ORGANIZER}`, roles: { owner: true }, participationStatus: 'accepted' }, + b: { calendarAddress: 'mailto:userb@example.org', roles: { attendee: true }, participationStatus: 'accepted' }, +}; + +// Five Mondays from 5 October, 10:00 in Berlin; userb declined 19 October +// alone - on 1.0 an override holding just that answer, as a PatchObject. +const master = { + id: 'b', uid: 'weekly@example.org', title: 'Planning', start: '2026-10-05T10:00:00', duration: 'PT1H', + timeZone: 'Europe/Berlin', calendarIds: { 'cal-1': true }, organizerCalendarAddress: `mailto:${ORGANIZER}`, + participants, isOrigin: true, + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 }], + recurrenceOverrides: { '2026-10-19T10:00:00': { 'participants/b/participationStatus': 'declined' } }, +} as unknown as CalendarEvent; + +const occurrence = { + ...master, id: 's19', baseEventId: 'b', start: '2026-10-19T10:00:00', recurrenceId: '2026-10-19T10:00:00', + utcStart: '2026-10-19T08:00:00Z', utcEnd: '2026-10-19T09:00:00Z', +} as CalendarEvent; + +beforeEach(() => { + vi.clearAllMocks(); + useCalendarStore.getState().reset(); + useCalendarStore.setState({ calendars: [{ id: 'cal-1', name: 'Work' }] as never, events: [occurrence], loadedRange: null }); + mockGetEvents.mockImplementation(async (ids: string[]) => (ids[0] === 'b' ? [master] : [])); +}); + +describe('this and the following occurrences on the calendar store (Stalwart 1.0)', () => { + it('puts the series back as it was when the create fails, and fails the save', async () => { + mockCreate.mockRejectedValue(new Error('Network request failed')); + const store = useCalendarStore.getState(); + + await expect(saveOccurrenceEdit('this_and_future', { + event: occurrence, + updates: { title: 'Planning, room 2', start: occurrence.start, duration: 'PT1H', timeZone: 'Europe/Berlin', participants }, + editor: { attendees: [{ name: '', email: 'userb@example.org' }], when: editorTimesFromEvent(occurrence) }, + calendarId: 'cal-1', sendScheduling: true, userEmails: [ORGANIZER], + }, { updateEvent: store.updateEvent, createEvent: store.createEvent, getMasterEvent: store.getMasterEvent })) + .rejects.toThrow('Network request failed'); + + expect(mockUpdate.mock.calls.map(([id, patch, schedule]) => [id, patch, schedule])).toEqual([ + ['b', { + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: null, until: '2026-10-19T09:59:59' }], + 'recurrenceOverrides/2026-10-19T10:00:00': null, + }, true], + // Its rule and the whole overrides map: a pointer fails on a series + // the truncation left without overrides. + ['b', { recurrenceRules: master.recurrenceRules, recurrenceOverrides: master.recurrenceOverrides }, true], + ]); + const [created, calendarId, schedule] = mockCreate.mock.calls[0]; + expect([calendarId, schedule]).toEqual(['cal-1', true]); + expect(created).toMatchObject({ start: '2026-10-19T10:00:00', recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 3 }] }); + }); +});