From 6785276377ab0771a3864f09700cf3cb2d5d2cca Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:00:45 +0200 Subject: [PATCH] fix: put back an event moved in place to another account's calendar #34 An app that moves an event by changing its calendar in place can move it into a calendar of another account on the device. A patch cannot move an event between accounts, so the move was dropped; with nothing else changed, the row was then marked clean where it stood. The phone showed the event in the other account's calendar while the server kept it in the old one, until the next server change moved it back. The row now goes back to its calendar at once, rewritten from the last server version. Changes made together with the move still upload, and their accepted write puts the row back as before. Checked with a run through the engine and the fake server with a shared account: the event is back in its calendar and clean, and the server and the other account are untouched. --- .../__tests__/calendar/engine.test.ts | 35 +++++++++++++++++++ src/device-sync/calendar/planner.ts | 7 ++++ 2 files changed, 42 insertions(+) diff --git a/src/device-sync/__tests__/calendar/engine.test.ts b/src/device-sync/__tests__/calendar/engine.test.ts index 836abbb3..76f4c661 100644 --- a/src/device-sync/__tests__/calendar/engine.test.ts +++ b/src/device-sync/__tests__/calendar/engine.test.ts @@ -131,6 +131,41 @@ describe('calendar device edits through the engine', () => { expect(h.events().every((e) => Number(e[Events.DIRTY] ?? 0) === 0)).toBe(true); }); + it('puts an event an app moved in place to a calendar of another account back into its calendar', async () => { + const h = real({ accounts: 'withShared' }); + const team = String(h.server.all('Calendar', 'team')[0].id); + h.prefs.calendarSelection[`team/${team}`] = true; + const id = h.server.addEvent('a', { + uid: 'lunch-uid', + title: 'Lunch', + start: '2026-10-06T12:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + calendarIds: { [h.calendar]: true }, + }); + await h.run(CALENDAR_AUTHORITY); + const calendarRow = (key: string) => Number(h.device.rows('calendars').find((c) => c._sync_id === key)!._id); + const row = () => h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!; + expect(row()[Events.CALENDAR_ID]).toBe(calendarRow(`a/${h.calendar}`)); + + // An app moves it by changing CALENDAR_ID in place (a move across JMAP accounts can't be a patch). + h.device.user.updateEvent(Number(row()._id), { [Events.CALENDAR_ID]: calendarRow(`team/${team}`) }); + const report = await h.run(CALENDAR_AUTHORITY); + + expect(row()).toMatchObject({ [Events.CALENDAR_ID]: calendarRow(`a/${h.calendar}`), [Events.DIRTY]: 0 }); + expect(h.server.get('CalendarEvent', 'a', id)!.calendarIds).toEqual({ [h.calendar]: true }); + expect(h.server.all('CalendarEvent', 'team')).toEqual([]); + // Nothing was sent; the change counts as skipped (a report entry needs the engine to report reverts). + expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 0, updated: 0, deleted: 0 }, skipped: 1 } }); + + // Together with another edit: that one uploads, and the row goes back all the same. + h.device.user.updateEvent(Number(row()._id), { [Events.CALENDAR_ID]: calendarRow(`team/${team}`), [Events.TITLE]: 'Team lunch' }); + expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', stats: { uploaded: { updated: 1 } } }); + expect(h.server.get('CalendarEvent', 'a', id)).toMatchObject({ title: 'Team lunch', calendarIds: { [h.calendar]: true } }); + expect(row()).toMatchObject({ [Events.CALENDAR_ID]: calendarRow(`a/${h.calendar}`), [Events.TITLE]: 'Team lunch', [Events.DIRTY]: 0 }); + expect(h.server.all('CalendarEvent', 'team')).toEqual([]); + }); + it('keeps a description and location cleared on one occurrence cleared', async () => { const h = real(); const id = h.server.addEvent('a', { diff --git a/src/device-sync/calendar/planner.ts b/src/device-sync/calendar/planner.ts index 4d6157ed..abda5068 100644 --- a/src/device-sync/calendar/planner.ts +++ b/src/device-sync/calendar/planner.ts @@ -316,6 +316,13 @@ function planUpload(local: LocalEvent, ctx: CalendarContext): Plan { } if (!keys.length) { if (computation.dropped.some((u) => u === 'timing' || u === 'rule')) return { kind: 'skip', reason: SKIP.ruleNotRepresentable }; + // A move into a calendar of another JMAP account is no patch (only new rows are created across accounts): + // the row goes back to its calendar instead of staying there unsynced. With other changes, their accepted + // write puts it back. + if (computation.dropped.includes('calendar')) { + const ops = revertGroup(local, ctx); + if (ops) return { kind: 'revert', ops }; + } return { kind: 'clean', ops: cleanGroup(local, computation) }; } const action: UploadAction = { kind: 'update', id: ref.id, patch: computation.patch };