diff --git a/src/lib/__tests__/recurrence-instances.test.ts b/src/lib/__tests__/recurrence-instances.test.ts index 70ec88e1..fcdc6e3c 100644 --- a/src/lib/__tests__/recurrence-instances.test.ts +++ b/src/lib/__tests__/recurrence-instances.test.ts @@ -480,3 +480,34 @@ describe('hydrateRecurrenceInstances with a reply stub', () => { expect(hydrateRecurrenceInstances([{ ...stub(), title: '' }], withOverride({ title: '' }))[0].title).toBe(''); }); }); + +// Until a562ea5 every editor save wrote the organizer into the override it +// rewrote; 0.16.19 refuses an answer to an occurrence whose override names +// none. An override on the base event (0.16 before 0.16.20, and ranges the +// device expands) gets the series' organizer back when it has none; through +// a synthetic id it is never sent. 1.0 refuses it inside an override once, +// and the store writes the override again without it. +describe.each(['0.16.19', '1.0'])('the organizer of an existing override written on the base event (Stalwart %s)', () => { + const occurrence = (entry: Record) => ({ + id: 'b:2026-09-08T10:00:00', originalId: 'b', start: '2026-09-08T10:00:00', recurrenceId: '2026-09-08T10:00:00', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' }], + organizerCalendarAddress: 'mailto:org@example.com', + recurrenceOverrides: { '2026-09-08T10:00:00': entry }, + }) as unknown as CalendarEvent; + + it('adds it to an override that has none', () => { + expect(withNewOverrideDetails(occurrence({ title: 'Moved' }), { title: 'Renamed' }, { seriesKeys: true })) + .toEqual({ title: 'Renamed', organizerCalendarAddress: 'mailto:org@example.com' }); + }); + + it('leaves an override that has one, and a patch that sets one, alone', () => { + const named = occurrence({ title: 'Moved', organizerCalendarAddress: 'mailto:org@example.com' }); + expect(withNewOverrideDetails(named, { title: 'Renamed' }, { seriesKeys: true })).toEqual({ title: 'Renamed' }); + expect(withNewOverrideDetails(occurrence({}), { organizerCalendarAddress: null } as never, { seriesKeys: true })) + .toEqual({ organizerCalendarAddress: null }); + }); + + it('sends nothing of the series through a synthetic id', () => { + expect(withNewOverrideDetails(occurrence({ title: 'Moved' }), { title: 'Renamed' })).toEqual({ title: 'Renamed' }); + }); +}); diff --git a/src/lib/__tests__/recurrence-scope-edit.test.ts b/src/lib/__tests__/recurrence-scope-edit.test.ts index e1fea595..888e7eab 100644 --- a/src/lib/__tests__/recurrence-scope-edit.test.ts +++ b/src/lib/__tests__/recurrence-scope-edit.test.ts @@ -720,7 +720,7 @@ describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('where "thi 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 deps = actions(series('1.0')); const saved = await saveOccurrenceEdit('all', { event: occurrence, @@ -733,8 +733,11 @@ describe('saveOccurrenceEdit for every occurrence', () => { 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 }); + // was stays the series' own. The attendees are told, and 19 October has + // an override: the SEQUENCE goes up (see below). + expect(deps.updateEvent).toHaveBeenCalledWith('b', { + title: 'Planning, room 2', sequence: 1, 'recurrenceOverrides/2026-10-19T10:00:00/sequence': 1, + }, { sendSchedulingMessages: true }); }); it('reports a series it cannot find', async () => { @@ -976,3 +979,263 @@ describe('one occurrence the server keeps as its own event', () => { expect(deps.createEvent).not.toHaveBeenCalled(); }); }); + +// 0.16 sends the change of one occurrence with its SEQUENCE raised, without +// keeping the raise itself. A later rename of every occurrence carried the +// lower number for it, and the attendee's server refused the whole change: +// the mail arrived, the copy stayed as it was (live on 0.16.23). 1.0 raises +// every occurrence's SEQUENCE the same way itself for a change it sends. +describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the SEQUENCE of a changed series on Stalwart %s', (_label, version) => { + // Five Mondays from 5 October, 19 October with an override: on 0.16 it + // holds a sequence of its own (copied when it got the override), on 1.0 + // the PatchObject leaves it out while it is the series'. + const withSequences = (overrides: CalendarEvent['recurrenceOverrides'] = series(version).recurrenceOverrides) => ({ + ...series(version), sequence: 2, + recurrenceOverrides: version === '1.0' + ? overrides + : Object.fromEntries(Object.entries(overrides ?? {}).map(([key, entry]) => [key, { ...entry, sequence: 2 }])), + }) as CalendarEvent; + const occurrenceOn26 = () => ({ + ...serverOccurrence(version), id: 'maaaaad', start: '2026-10-26T10:00:00', recurrenceId: '2026-10-26T10:00:00', + utcStart: '2026-10-26T09:00:00Z', utcEnd: '2026-10-26T10:00:00Z', + }) as CalendarEvent; + const save = async ( + master: CalendarEvent, + change: Partial, + extra: { sendScheduling?: boolean } = {}, + editor: Partial = {}, + ) => { + const occurrence = occurrenceOn26(); + const deps = actions(master); + await saveOccurrenceEdit('all', { + event: occurrence, updates: editorUpdates(occurrence, change), editor: editorOn(occurrence, editor), + sendScheduling: true, userEmails: [ORGANIZER], ...extra, + }, deps); + return deps.updateEvent.mock.calls[0]; + }; + + it('raises the series\' and each changed occurrence\'s by one on a new title', async () => { + const [id, patch, options] = await save(withSequences(), { title: 'Planning, room 2' }); + + expect([id, options]).toEqual(['b', { sendSchedulingMessages: true }]); + expect(patch).toEqual({ title: 'Planning, room 2', sequence: 3, 'recurrenceOverrides/2026-10-19T10:00:00/sequence': 3 }); + }); + + it('raises them inside the overrides when the series moves', async () => { + const { start, end } = editorTimesFromEvent(occurrenceOn26()); + const retitled = withSequences({ '2026-10-19T10:00:00': { title: 'Planning, special' } } as never); + const [, patch] = await save(retitled, {}, {}, { when: { allDay: false, start: addHours(start, 1), end: addHours(end, 1) } }); + + const raised = patch as Record & { recurrenceOverrides: Record }; + expect(raised.sequence).toBe(3); + expect(raised.recurrenceOverrides['2026-10-19T11:00:00'].sequence).toBe(3); + expect(Object.keys(raised).some((key) => key.startsWith('recurrenceOverrides/'))).toBe(false); + }); + + it('leaves them alone when nothing goes out, nothing the attendees see changes, or no occurrence has an override', async () => { + const quiet = await save(withSequences(), { title: 'Planning, room 2' }, { sendScheduling: false }); + const alerts = await save(withSequences(), { + alerts: { a1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' }, action: 'display' } } as never, + }); + const plain = await save({ ...withSequences(), recurrenceOverrides: null } as CalendarEvent, { title: 'Planning, room 2' }); + + for (const [, patch] of [quiet, alerts, plain]) { + expect(patch).not.toHaveProperty('sequence'); + expect(Object.keys(patch).some((key) => key.endsWith('/sequence'))).toBe(false); + } + }); + + it('leaves them alone on an attendee\'s own copy', async () => { + const occurrence = occurrenceOn26(); + const deps = actions({ ...withSequences(), isOrigin: false }); + + await saveOccurrenceEdit('all', { + event: occurrence, updates: editorUpdates(occurrence, { title: 'My name for it', participants: undefined }), editor: editorOn(occurrence), + sendScheduling: false, userEmails: [ATTENDEE], + }, deps); + + expect(deps.updateEvent.mock.calls[0][1]).toEqual({ title: 'My name for it' }); + }); +}); + +// 0.16 (0.16.23 checked) reads UNTIL as the series' wall clock taken for +// UTC: in New York "until 09:59:59" kept the 10:00 occurrence it was to end +// before (live). Five hours earlier ends the series there on 0.16 and 1.0. +describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('ending a series in a zone behind UTC on Stalwart %s', (_label, version) => { + const mondays = (start: string, utc: string) => ({ + ...series(version), start, timeZone: 'America/New_York', utcStart: utc, utcEnd: utc, + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], recurrenceOverrides: null, + }) as unknown as CalendarEvent; + const occurrenceOf = (master: CalendarEvent, recurrenceId: string, utc: string) => ({ + ...master, id: 'maaaaac', baseEventId: 'b', start: recurrenceId, recurrenceId, recurrenceIdTimeZone: 'America/New_York', + utcStart: utc, utcEnd: utc, + }) as CalendarEvent; + const untilOf = (patch: unknown) => (patch as { recurrenceRules: { until: string }[] }).recurrenceRules[0].until; + + it('ends it early enough for 0.16', async () => { + const master = mondays('2026-12-07T10:00:00', '2026-12-07T15:00:00Z'); + const deps = actions(master); + + await deleteOccurrence('this_and_future', { event: occurrenceOf(master, '2026-12-21T10:00:00', '2026-12-21T15:00:00Z'), userEmails: [ORGANIZER] }, deps); + + expect(deps.updateEvent.mock.calls[0][1]).toEqual({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', until: '2026-12-21T04:59:59', count: null }], + }); + }); + + it('takes the larger offset when the clocks changed the day before', async () => { + // Sunday 8 March 2026: New York went to summer time that night (UTC-5 + // on Saturday, UTC-4 from Sunday 03:00). + const master = { ...mondays('2026-03-01T10:00:00', '2026-03-01T15:00:00Z'), recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' }] } as CalendarEvent; + const deps = actions(master); + + await deleteOccurrence('this_and_future', { event: occurrenceOf(master, '2026-03-08T10:00:00', '2026-03-08T14:00:00Z'), userEmails: [ORGANIZER] }, deps); + + expect(untilOf(deps.updateEvent.mock.calls[0][1])).toBe('2026-03-08T04:59:59'); + }); + + it('ends a split the same way', async () => { + const master = mondays('2026-12-07T10:00:00', '2026-12-07T15:00:00Z'); + const occurrence = occurrenceOf(master, '2026-12-21T10:00:00', '2026-12-21T15:00:00Z'); + 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(untilOf(deps.updateEvent.mock.calls[0][1])).toBe('2026-12-21T04:59:59'); + }); + + it('ends a floating or all-day series a second before, as before', async () => { + const floating = { ...mondays('2026-12-07T10:00:00', '2026-12-07T15:00:00Z'), timeZone: null } as CalendarEvent; + const deps = actions(floating); + + await deleteOccurrence('this_and_future', { event: occurrenceOf(floating, '2026-12-21T10:00:00', '2026-12-21T15:00:00Z'), userEmails: [ORGANIZER] }, deps); + + expect(untilOf(deps.updateEvent.mock.calls[0][1])).toBe('2026-12-21T09:59:59'); + }); +}); + +// A series the app has not expanded - one opened from a reminder or a +// link, one an edit just made recurring - shows as the series itself, once, +// at its first occurrence. "This occurrence" of it went to the series: the +// editor's data rewrote every occurrence, and deleting it deleted the series. +describe.each<[string, Version]>([['1.0', '1.0'], ['0.16', '0.16']])('the series itself standing for its first occurrence on Stalwart %s', (_label, version) => { + // As CalendarEvent/get returns the base event. + const base = (overrides: Partial = {}) => ({ + ...series(version), recurrenceOverrides: null, ...overrides, + }) as CalendarEvent; + const kickOff = (master: CalendarEvent) => editorUpdates(master, { title: 'Kick-off' }); + type Call = [string, Record>>, unknown]; + + // userb accepted the series. Stalwart sends a new override only to + // attendees who have not answered: the change went out in one write that + // reached nobody who had. It now takes the store's two steps. + it('changes only the first occurrence for "this occurrence", in two steps for those who answered', async () => { + const master = base(); + const deps = actions(master); + + const saved = await saveOccurrenceEdit('this', { + event: master, updates: kickOff(master), sendScheduling: true, userEmails: [ORGANIZER], + }, deps); + + expect(saved).toBe(true); + expect(deps.updateEvent).toHaveBeenCalledTimes(2); + // First the occurrence as it is, without messages: the whole map, as the + // series has no overrides yet; nothing of the series changes. + const [id, copy, quiet] = deps.updateEvent.mock.calls[0] as unknown as Call; + expect([id, quiet]).toEqual(['b', { sendSchedulingMessages: false }]); + expect(Object.keys(copy)).toEqual(['recurrenceOverrides']); + expect(copy.recurrenceOverrides['2026-10-05T10:00:00']).toMatchObject({ start: '2026-10-05T10:00:00', title: 'Planning' }); + // Then the change of that override, with messages. + const [again, change, send] = deps.updateEvent.mock.calls[1] as unknown as Call; + expect([again, send]).toEqual(['b', { sendSchedulingMessages: true }]); + expect(Object.keys(change)).toEqual(['recurrenceOverrides/2026-10-05T10:00:00']); + expect(change['recurrenceOverrides/2026-10-05T10:00:00']).toMatchObject({ start: '2026-10-05T10:00:00', title: 'Kick-off' }); + expect(statusOf(change['recurrenceOverrides/2026-10-05T10:00:00'].participants as CalendarEvent['participants'], ATTENDEE)).toBe('accepted'); + }); + + it('changes it in one write when nobody has answered, or nothing is sent', async () => { + const unanswered = base({ + participants: { org: participant(version, ORGANIZER, true, 'accepted'), b: participant(version, ATTENDEE, false, 'needs-action') }, + }); + for (const [master, send] of [[unanswered, true], [base(), false]] as const) { + const deps = actions(master); + + await saveOccurrenceEdit('this', { event: master, updates: kickOff(master), sendScheduling: send, userEmails: [ORGANIZER] }, deps); + + expect(deps.updateEvent).toHaveBeenCalledTimes(1); + const [id, patch, options] = deps.updateEvent.mock.calls[0] as unknown as Call; + expect([id, options]).toEqual(['b', { sendSchedulingMessages: send }]); + expect(patch.recurrenceOverrides['2026-10-05T10:00:00']).toMatchObject({ title: 'Kick-off' }); + } + }); + + it('adds the override to those the series has', async () => { + const master = base({ recurrenceOverrides: series(version).recurrenceOverrides }); + const deps = actions(master); + + await saveOccurrenceEdit('this', { event: master, updates: kickOff(master), sendScheduling: true, userEmails: [ORGANIZER] }, deps); + + expect(Object.keys(deps.updateEvent.mock.calls[0][1])).toEqual(['recurrenceOverrides/2026-10-05T10:00:00']); + }); + + it('changes the whole series for "this and following" from its first occurrence', async () => { + const master = base(); + const deps = actions(master); + + await saveOccurrenceEdit('this_and_future', { + event: master, updates: kickOff(master), editor: editorOn(master), sendScheduling: true, userEmails: [ORGANIZER], + }, deps); + + // Not ended before its first occurrence, no new series: the series itself changes. + expect(deps.createEvent).not.toHaveBeenCalled(); + expect(deps.updateEvent.mock.calls).toEqual([['b', { title: 'Kick-off' }, { sendSchedulingMessages: true }]]); + }); + + it('changes or deletes the whole series for "this and following" from the first occurrence of an expanded series too', async () => { + const master = base(); + const first = { ...master, id: 'maaaaab', baseEventId: 'b', recurrenceId: '2026-10-05T10:00:00', recurrenceIdTimeZone: 'Europe/Berlin' } as CalendarEvent; + const deps = actions(master); + + await saveOccurrenceEdit('this_and_future', { + event: first, updates: kickOff(first), editor: editorOn(first), sendScheduling: true, userEmails: [ORGANIZER], + }, deps); + await deleteOccurrence('this_and_future', { event: first, userEmails: [ORGANIZER] }, deps); + + expect(deps.createEvent).not.toHaveBeenCalled(); + expect(deps.updateEvent.mock.calls).toEqual([['b', { title: 'Kick-off' }, { sendSchedulingMessages: true }]]); + expect(deps.deleteEvent.mock.calls).toEqual([['b']]); + }); + + it('excludes the first occurrence for "delete this occurrence"', async () => { + const master = base(); + const deps = actions(master); + + const deleted = await deleteOccurrence('this', { event: master, userEmails: [ORGANIZER] }, deps); + + expect(deleted).toBe(true); + expect(deps.deleteEvent).not.toHaveBeenCalled(); + expect(deps.updateEvent.mock.calls).toEqual([['b', { recurrenceOverrides: { '2026-10-05T10:00:00': { excluded: true } } }]]); + }); + + it('deletes the whole series for "delete this and following" from its first occurrence', async () => { + const master = base(); + const deps = actions(master); + + await deleteOccurrence('this_and_future', { event: master, userEmails: [ORGANIZER] }, deps); + + expect(deps.deleteEvent).toHaveBeenCalledWith('b'); + expect(deps.updateEvent).not.toHaveBeenCalled(); + }); + + it('refuses rather than touch a series it cannot find', async () => { + const deps = actions(null); + + expect(await saveOccurrenceEdit('this', { event: base(), updates: { title: 'x' }, userEmails: [ORGANIZER] }, deps)).toBe(false); + expect(await deleteOccurrence('this', { event: base(), userEmails: [ORGANIZER] }, deps)).toBe(false); + expect(deps.updateEvent).not.toHaveBeenCalled(); + expect(deps.deleteEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/recurrence-instances.ts b/src/lib/recurrence-instances.ts index 2acd18e7..2f2a6b9d 100644 --- a/src/lib/recurrence-instances.ts +++ b/src/lib/recurrence-instances.ts @@ -542,6 +542,18 @@ export function isBrowserExpandedOccurrence( type OccurrenceOverrideContext = Pick & Partial>; +/** + * `occurrence` as seen from `master`, the base event it belongs to, for + * building an override patch on the master: the master's overrides map is + * the one that counts. + */ +export function overrideContextOf( + occurrence: CalendarEvent, + master: Pick, +): CalendarEvent { + return { ...occurrence, baseEventId: undefined, recurrenceOverrides: master.recurrenceOverrides ?? null }; +} + /** * The base event's overrides map as far as `occurrence` knows it, or * `undefined` when it does not. The device's expansion copies the base @@ -654,18 +666,37 @@ export interface OverridePatchOptions { seriesKeys?: boolean; } +/** + * `patch` for an occurrence's existing override on the base event, with the + * series' organizer when that override names none - as every editor save + * wrote it before 2a3febc: 0.16.19 refuses an answer to one occurrence whose + * override has no organizer (see buildOccurrenceRsvpPatch). + * 0.16.23 takes the key and drops it; Stalwart 1.0 refuses it inside an + * override, and the store then writes the override once more without series + * keys. Through a synthetic id it is never sent. + */ +function withOverrideOrganizer(occurrence: Partial, patch: Partial): Partial { + if (!occurrence.organizerCalendarAddress || 'organizerCalendarAddress' in patch || !occurrence.recurrenceId) return patch; + const overrides = knownOverrides(occurrence); + const key = resolveOverrideKey({ start: occurrence.start ?? '', recurrenceId: occurrence.recurrenceId }, overrides); + const entry = key ? overrides?.[key] : undefined; + if (!entry || 'organizerCalendarAddress' in entry) return patch; + return { ...patch, organizerCalendarAddress: occurrence.organizerCalendarAddress }; +} + /** * `patch` plus the occurrence details a new override needs * (NEW_OVERRIDE_COPIED_KEYS). Nothing is added when the occurrence already * has an override - an attendee may not add most of these to the organizer's - * - or when that is unknown. + * - or when that is unknown; an existing override written on the base event + * gets the series' organizer if it has none (withOverrideOrganizer). */ export function withNewOverrideDetails( occurrence: Partial, patch: Partial, options: OverridePatchOptions = {}, ): Partial { - if (!createsOverride(occurrence)) return patch; + if (!createsOverride(occurrence)) return options.seriesKeys ? withOverrideOrganizer(occurrence, patch) : patch; const names: readonly string[] = options.seriesKeys ? [...NEW_OVERRIDE_COPIED_KEYS, ...NEW_OVERRIDE_SERIES_KEYS] : NEW_OVERRIDE_COPIED_KEYS; diff --git a/src/lib/recurrence-scope-edit.ts b/src/lib/recurrence-scope-edit.ts index 950322fd..6d615179 100644 --- a/src/lib/recurrence-scope-edit.ts +++ b/src/lib/recurrence-scope-edit.ts @@ -11,6 +11,7 @@ import type { CalendarEvent, RecurrenceRule } from '../api/types'; import type { RecurrenceEditScope } from '../components/calendar/RecurrenceScopeDialog'; import { getEventStartDate, getPrimaryCalendarId } from './calendar-utils'; import { + hasAnsweredAttendees, participantsAskedAgain, seriesParticipantFields, userOrganizes, @@ -19,11 +20,18 @@ import { } from './calendar-participants'; import { editorTimesChanged, type EditorTimes } from './event-editor-times'; import { + buildFallbackExcludePatch, + buildFallbackOverridePatch, buildOccurrencePatch, + buildOverrideInTwoSteps, + createsOverride, + isBrowserExpandedOccurrence, isServerRecurrenceInstance, + overrideContextOf, parseDurationSeconds, resolveOverrideKey, stableJson, + withNewOverrideDetails, } from './recurrence-instances'; import { occurrencesBefore, seriesOccursAt } from './recurrence-expansion'; import { recurrenceRulesEqual } from './recurrence'; @@ -31,6 +39,7 @@ import { formatWallClock, fromDisplayDate, getEffectiveTimeZone, + getTimeZoneOffsetMs, isValidTimeZone, localDateTimeToInstant, } from './calendar-timezone'; @@ -437,6 +446,61 @@ export function seriesUpdatesFromOccurrence( return series; } +/** Changes of a series its attendees see - Stalwart announces them, and 1.0 raises the SEQUENCE for them. */ +const ANNOUNCED_KEYS = [ + 'title', 'description', 'locations', 'virtualLocations', 'status', 'participants', 'recurrenceRules', + 'excludedRecurrenceRules', 'recurrenceOverrides', ...WHEN_KEYS, +] as const; + +/** + * `series`, a change to the series `master` its attendees are told of, with + * the SEQUENCE of the series and of each of its changed occurrences one up. + * + * 0.16 (0.16.6 to 0.16.23) sends the change of one occurrence with that + * occurrence's SEQUENCE raised, but does not keep the raise itself (in + * organizer.rs the list of components to raise is shadowed for such + * changes). A later change of every occurrence that does not raise the + * number again - a new title - then carried a lower SEQUENCE for that + * occurrence than the attendee's copy has, and the attendee's server + * refused the whole change as out of sequence: the mail arrived, the copy + * stayed as it was (live on 0.16.23). One up from what the organizer's + * copy holds is at least what the attendees got. 1.0 raises every + * occurrence's SEQUENCE the same way itself for a change it announces, and + * keeps a number it would have given (live: the same result with and + * without). The app's update sends `sequence` as it is; nothing strips it. + */ +function withRaisedSequences(series: Partial, master: CalendarEvent): Record { + const base = master.sequence ?? 0; + const raised: Record = { ...series, sequence: base + 1 }; + const raise = (entry: OverrideEntry) => (entry.excluded ? entry : { ...entry, sequence: Number(entry.sequence ?? base) + 1 }); + if (series.recurrenceOverrides !== undefined) { + if (series.recurrenceOverrides) { + raised.recurrenceOverrides = Object.fromEntries(Object.entries(series.recurrenceOverrides) + .map(([key, entry]) => [key, raise(entry as OverrideEntry)])); + } + } else { + for (const [key, entry] of Object.entries(master.recurrenceOverrides ?? {})) { + if (!(entry as OverrideEntry).excluded) raised[`recurrenceOverrides/${key}/sequence`] = raise(entry as OverrideEntry).sequence; + } + } + return raised; +} + +/** + * Whether saving `series` for every occurrence of `master` raises their + * SEQUENCE (withRaisedSequences): the user organizes the series and tells + * its attendees of a change they see, and it has occurrences of its own a + * change of one of them may have left behind. + */ +function raisesSequences(series: Partial, master: CalendarEvent, edit: OccurrenceEdit): boolean { + return !!edit.sendScheduling + && !!edit.editor + && Object.keys(master.participants ?? {}).length > 0 + && userOrganizes(master, edit.userEmails) + && Object.values(master.recurrenceOverrides ?? {}).some((entry) => !(entry as OverrideEntry).excluded) + && ANNOUNCED_KEYS.some((key) => key in series); +} + /** The calendar store's scheduling switch for one write: true sends the attendees messages, false is quiet. */ interface SchedulingOptions { sendSchedulingMessages?: boolean; @@ -494,6 +558,58 @@ async function seriesOf(event: CalendarEvent, actions: Pick 0 ? master : null; } +/** A series itself, not one of its occurrences: it has rules and no recurrence id. */ +function isSeries(event: CalendarEvent): boolean { + return (event.recurrenceRules?.length ?? 0) > 0 && !event.recurrenceId; +} + +/** + * `event` as an occurrence of `master`, to override or exclude on it: itself + * when it is one, the series' first occurrence when it is the series itself. + * The app shows a series it has not expanded - one opened from a reminder or + * a link, one an edit just made recurring - as the series, once, at its + * first occurrence; a change to "this occurrence" of it went to the series + * and rewrote every occurrence, and deleting it deleted them all. + */ +function asOccurrenceOf(event: CalendarEvent, master: CalendarEvent): CalendarEvent { + const occurrence = event.recurrenceId + ? event + : { ...event, recurrenceId: master.start, recurrenceIdTimeZone: master.timeZone ?? null }; + return overrideContextOf(occurrence, master); +} + +/** Whether `event` is `master`'s first occurrence, or the series itself standing for it. */ +function isFirstOccurrence(event: CalendarEvent, master: CalendarEvent): boolean { + if (!event.recurrenceId) return true; + return wallClockMs(event.recurrenceId) === wallClockMs(master.start); +} + +/** + * Change `occurrence` of `master`, which has no override yet, so that the + * change reaches every attendee who has not declined - in the two steps the + * calendar store takes for an occurrence of an expanded series + * (updateOccurrenceInTwoSteps). Stalwart sends a new override only to + * attendees who have not answered, a change to an existing one to all who + * have not declined: the occurrence first gets its override as it is, + * without messages, and the change then goes out as a change of it. This is + * the series itself standing for its first occurrence, which changed in one + * write that reached nobody who had answered. The override is written on + * the series, under the key it keeps, so the second step needs no fresh + * read. + */ +async function overrideForEveryone( + occurrence: CalendarEvent, + master: CalendarEvent, + updates: Partial, + actions: Pick, +): Promise { + const steps = buildOverrideInTwoSteps(occurrence, updates); + if (!steps) return false; + await actions.updateEvent(master.id, steps.quiet, { sendSchedulingMessages: false }); + await actions.updateEvent(master.id, steps.change, { sendSchedulingMessages: true }); + return true; +} + /** 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; @@ -521,7 +637,7 @@ async function truncateSeries( ? JSON.parse(JSON.stringify(master.recurrenceRules)) : null; const splitKey = splitKeyOf(occurrence, master); - const until = untilBefore(splitKey); + const until = untilBefore(splitKey, master); const truncatedRules = (master.recurrenceRules || []).map((rule) => ({ ...rule, until, @@ -545,9 +661,29 @@ async function truncateSeries( 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 UNTIL that ends `series` just before its occurrence at `splitKey`: a + * second before it, on the series' own clock - and earlier by as much as the + * series' zone is behind UTC. 0.16 (0.16.23 checked) takes the UNTIL for + * the series' wall clock, turns it into UTC and compares that with the + * wall clock of each occurrence: in New York "until 09:59:59" kept the + * 10:00 occurrence it was to end before, "until 04:59:59" ends before it. + * 1.0 reads the UNTIL on the series' clock, and ends it there either way as + * long as its occurrences are further apart than that - hours, for a daily + * or weekly series never. The offset is the larger of the split's day and + * the day before, for a change of daylight saving time between them. + */ +function untilBefore(splitKey: string, series: CalendarEvent): string { + let back = 1000; + const zone = !series.showWithoutTime && series.timeZone && isValidTimeZone(series.timeZone) ? series.timeZone : null; + if (zone) { + const instant = localDateTimeToInstant(splitKey.slice(0, 19), zone); + if (instant) { + const offsets = [instant, new Date(instant.getTime() - 86_400_000)].map((at) => getTimeZoneOffsetMs(at, zone)); + back += Math.max(0, -Math.min(...offsets)); + } + } + return shiftLocalDateTime(splitKey, -back); } /** @@ -723,15 +859,45 @@ export async function saveOccurrenceEdit( 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. + if (scope === 'this') { + if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(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; + } + // The series itself standing for its first occurrence: override that + // occurrence on the series, never write it to the series itself. + const master = await seriesOf(event, actions); + if (master) { + const occurrence = asOccurrenceOf(event, master); + const participants = updates.participants === undefined ? occurrence.participants : updates.participants; + if (sendScheduling && createsOverride(occurrence) && userOrganizes({ ...occurrence, participants }, edit.userEmails) + && hasAnsweredAttendees(participants)) { + return overrideForEveryone(occurrence, master, updates, actions); + } + const overridePatch = buildFallbackOverridePatch(occurrence, withNewOverrideDetails(occurrence, updates, { seriesKeys: true })); + if (!overridePatch) return false; + await updateEvent(master.id, overridePatch, scheduling); + return true; + } + // An occurrence the server keeps as an event of its own (an invitation + // to that occurrence alone) is itself what changes; a series that + // cannot be found is not. + if (isSeries(event)) return false; + await updateEvent(event.id, updates, scheduling); + return true; + } + if (isLoneOccurrence(event)) { await updateEvent(event.id, updates, scheduling); return true; } const master = await seriesOf(event, actions); if (!master) return false; + // From the first occurrence on is the whole series; ended before its first + // occurrence, the series would be left with none. + if (scope === 'this_and_future' && isFirstOccurrence(event, master)) return saveOccurrenceEdit('all', edit, actions); if (scope === 'this_and_future') { const { restore, announced, removed } = await truncateSeries(master, event, actions, { sendScheduling, userEmails: edit.userEmails, @@ -768,7 +934,8 @@ export async function saveOccurrenceEdit( seriesUpdates = { ...updates }; delete seriesUpdates.recurrenceId; } - await updateEvent(master.id, seriesUpdates, scheduling); + const write = raisesSequences(seriesUpdates, master, edit) ? withRaisedSequences(seriesUpdates, master) : seriesUpdates; + await updateEvent(master.id, write, scheduling); return true; } @@ -789,15 +956,34 @@ export async function deleteOccurrence( 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. + if (scope === 'this') { + if (isServerRecurrenceInstance(event) || isBrowserExpandedOccurrence(event)) { + // The store destroys a server occurrence, or excludes one the device + // expanded on its base event. + await actions.deleteEvent(event.id); + return true; + } + // The series itself standing for its first occurrence: exclude that + // occurrence on the series, never delete the series. The store tells + // the attendees as it does for its own deletes. + const master = await seriesOf(event, actions); + if (master) { + const excludePatch = buildFallbackExcludePatch(asOccurrenceOf(event, master)); + if (!excludePatch) return false; + await actions.updateEvent(master.id, excludePatch); + return true; + } + if (isSeries(event)) return false; + await actions.deleteEvent(event.id); + return true; + } + if (isLoneOccurrence(event)) { await actions.deleteEvent(event.id); return true; } const master = await seriesOf(event, actions); if (!master) return false; - if (scope === 'this_and_future') { + if (scope === 'this_and_future' && !isFirstOccurrence(event, master)) { await truncateSeries(master, event, actions, { sendScheduling: true, userEmails: deletion.userEmails }); return true; } diff --git a/src/stores/__tests__/calendar-store-series-edits.test.ts b/src/stores/__tests__/calendar-store-series-edits.test.ts index 93a2e2dd..c6c5e572 100644 --- a/src/stores/__tests__/calendar-store-series-edits.test.ts +++ b/src/stores/__tests__/calendar-store-series-edits.test.ts @@ -99,3 +99,96 @@ describe('this and the following occurrences on the calendar store (Stalwart 1.0 expect(created).toMatchObject({ start: '2026-10-19T10:00:00', recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 3 }] }); }); }); + +// The series itself, opened from a reminder or a link: not in the grid, and +// on a calendar shared with the user (the owner's account 'f'). "This +// occurrence" of it wrote the editor's data to the whole series before; it +// is an override of its first occurrence now, and has to reach the owner's +// account under the raw id - the store knew nothing of the series. +describe('the series itself opened from a reminder on the calendar store', () => { + const shared = { + ...master, id: 'f:b', originalId: 'b', accountId: 'f', isShared: true, calendarIds: { 'f:cal': true }, + recurrenceOverrides: null, + } as CalendarEvent; + + it.each(['0.16.23', '1.0'])('overrides its first occurrence in the owner\'s account on Stalwart %s', async () => { + useCalendarStore.setState({ events: [] }); + const store = useCalendarStore.getState(); + + await saveOccurrenceEdit('this', { + event: shared, + updates: { title: 'Kick-off', start: shared.start, duration: 'PT1H', timeZone: 'Europe/Berlin' }, + sendScheduling: false, userEmails: [ORGANIZER], + }, { updateEvent: store.updateEvent, createEvent: store.createEvent, getMasterEvent: store.getMasterEvent }); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + const [id, patch, schedule, accountId] = mockUpdate.mock.calls[0]; + expect([id, schedule, accountId]).toEqual(['b', false, 'f']); + expect(Object.keys(patch)).toEqual(['recurrenceOverrides']); + expect((patch as { recurrenceOverrides: Record }).recurrenceOverrides['2026-10-05T10:00:00']) + .toMatchObject({ start: '2026-10-05T10:00:00', title: 'Kick-off' }); + }); +}); + +// A change that makes an event a series (or a single event again) changes +// which occurrences the grid shows, and only a range load expands a series: +// until then the grid had the series itself, once (0.16 before 0.16.20, +// where nothing else reloads the range). +describe('an event an edit makes recurring (Stalwart 0.16.19)', () => { + it('loads the range again, and not for a single event that stays one', async () => { + const single = (id: string) => ({ + id, uid: `${id}@example.org`, title: 'Once', start: '2026-10-05T10:00:00', duration: 'PT1H', calendarIds: { 'cal-1': true }, + }) as CalendarEvent; + const refresh = vi.fn(async () => undefined); + useCalendarStore.setState({ events: [single('x'), single('y')], refresh }); + + await useCalendarStore.getState().updateEvent('y', { title: 'Once, renamed' }); + expect(refresh).not.toHaveBeenCalled(); + + await useCalendarStore.getState().updateEvent('x', { recurrenceRules: [{ frequency: 'weekly' }] }); + expect(refresh).toHaveBeenCalledTimes(1); + }); +}); + +// The raised SEQUENCE of an "all" change (0.16.6 to 0.16.23 keep no raise of +// a single occurrence's change) goes out as it is: the app's update strips +// no `sequence`, so nothing has to keep it. +describe.each(['0.16.23', '1.0'])('an announced change of every occurrence through the store on Stalwart %s', () => { + it('sends the raised SEQUENCE of the series and of its changed occurrence', async () => { + mockGetEvents.mockImplementation(async (ids: string[]) => (ids[0] === 'b' ? [{ ...master, sequence: 4 }] : [])); + const store = useCalendarStore.getState(); + + await saveOccurrenceEdit('all', { + 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) }, + sendScheduling: true, userEmails: [ORGANIZER], + }, { updateEvent: store.updateEvent, createEvent: store.createEvent, getMasterEvent: store.getMasterEvent }); + + expect(mockUpdate.mock.calls.map(([id, patch, schedule]) => [id, patch, schedule])).toEqual([ + ['b', { title: 'Planning, room 2', sequence: 5, 'recurrenceOverrides/2026-10-19T10:00:00/sequence': 5 }, true], + ]); + }); +}); + +// An override written on the base event by an older save, without the +// organizer: 0.16.19 refuses an answer to such an occurrence, so a change of +// it writes the series' organizer back (0.16 before 0.16.20: the device +// expands the series). +describe('a change of an occurrence whose override has no organizer (Stalwart 0.16.19)', () => { + it('writes the series\' organizer into the override', async () => { + const deviceOccurrence = { + ...master, id: 'b:2026-10-19T10:00:00', originalId: 'b', start: '2026-10-19T10:00:00', recurrenceId: '2026-10-19T10:00:00', + recurrenceOverrides: { '2026-10-19T10:00:00': { title: 'Planning, special', start: '2026-10-19T10:00:00', duration: 'PT1H' } }, + } as CalendarEvent; + useCalendarStore.setState({ events: [deviceOccurrence] }); + + await useCalendarStore.getState().updateEvent(deviceOccurrence.id, { title: 'Planning, room 2' }, { sendSchedulingMessages: true }); + + const [id, patch] = mockUpdate.mock.calls[0]; + expect(id).toBe('b'); + expect((patch as Record>)['recurrenceOverrides/2026-10-19T10:00:00']).toMatchObject({ + title: 'Planning, room 2', organizerCalendarAddress: `mailto:${ORGANIZER}`, + }); + }); +}); diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 515b6074..eaeefa45 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -954,7 +954,11 @@ export const useCalendarStore = create()( hasSchedulingParticipants(storeEvent) ? true : undefined); + // A change that makes an event a series, or a single event again, changes + // which occurrences the grid shows: only a range load expands a series, + // and until then the grid had the series itself, once. const touchesSeries = (!!storeEvent && isRecurringSeriesMember(storeEvent)) + || 'recurrenceRules' in patch || hasServerOccurrencesOf(get().events, realId, accountId); // An occurrence's alerts that turn the calendar's defaults off. const stopsDefaultAlerts = patch.useDefaultAlerts === false && 'alerts' in patch; @@ -1041,6 +1045,10 @@ export const useCalendarStore = create()( getMasterEvent: async (event) => { if (event.recurrenceRules?.length && !event.recurrenceId && !isServerRecurrenceInstance(event)) { + // The series itself, opened from a reminder or a link rather than the + // grid: known from here on, so a change to it is sent to its own + // account under its raw id (resolveMutationTarget). + if (!get().events.some((e) => e.id === event.id)) knownMasters.set(event.id, event); return event; } // Expansion replaces the master with its occurrences, each pointing back