From 232e892328bbc13c18562ca5eee1a9d6b1630093 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:57:28 +0200 Subject: [PATCH] fix: keep an occurrence's description or location empty once cleared #34 Clearing the description or location of one occurrence of a series in a calendar app removed the value from the occurrence's override. An override without a value of its own shows the series' one, so the write after the upload put "Daily agenda" or "Room 1" back into the occurrence and made it clean: the user's edit was gone without a trace. An existing override now gets an explicit empty value while the series has one to fall back to: the empty text for the description, and a location without a name, the only empty forms Stalwart keeps (calcard writes no LOCATION line for an empty map, which then reads back as the series' locations). Without a series value the override's own is removed as before. New exception rows keep treating an empty value as "inherit". Checked with a planner test and a run through the engine and the fake server: the row stays cleared and clean, and the echo writes nothing. --- .../__tests__/calendar/engine.test.ts | 47 +++++++++++++++++++ .../__tests__/calendar/exceptions.test.ts | 43 +++++++++++++++++ src/device-sync/calendar/upload.ts | 20 +++++++- 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 src/device-sync/__tests__/calendar/engine.test.ts diff --git a/src/device-sync/__tests__/calendar/engine.test.ts b/src/device-sync/__tests__/calendar/engine.test.ts new file mode 100644 index 00000000..d7d30337 --- /dev/null +++ b/src/device-sync/__tests__/calendar/engine.test.ts @@ -0,0 +1,47 @@ +// The calendar planner through the engine and the fake JMAP server: device +// edits reach the server as the user made them and the rows stay as the user +// left them, end to end. Mapping details are the planner's own tests. + +import { describe, expect, it } from 'vitest'; +import { Events } from '../../android-columns'; +import { calendarPlanner } from '../../calendar/planner'; +import { contactsPlanner } from '../../contacts/planner'; +import { CALENDAR_AUTHORITY, createHarness, type Harness } from '../engine/harness'; + +function real(options: Parameters[0] = {}): Harness { + const h = createHarness(options); + h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner }; + return h; +} + +type Overrides = Record>; + +describe('calendar device edits through the engine', () => { + it('keeps a description and location cleared on one occurrence cleared', async () => { + const h = real(); + const id = h.server.addEvent('a', { + uid: 'standup-uid', + title: 'Standup', + description: 'Daily agenda', + start: '2026-09-28T09:00:00', + duration: 'PT15M', + timeZone: 'Europe/Berlin', + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 }, + locations: { locA: { '@type': 'Location', name: 'Room 1' } }, + recurrenceOverrides: { + '2026-09-29T09:00:00': { title: 'Standup', description: 'Special agenda', locations: { locB: { '@type': 'Location', name: 'Room 2' } } }, + }, + calendarIds: { [h.calendar]: true }, + }); + await h.run(CALENDAR_AUTHORITY); + const exception = () => h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!; + expect(exception()).toMatchObject({ [Events.DESCRIPTION]: 'Special agenda', [Events.EVENT_LOCATION]: 'Room 2' }); + + h.device.user.updateEvent(Number(exception()._id), { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null }); + expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } }); + + expect(exception()).toMatchObject({ [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null, [Events.DIRTY]: 0 }); + const overrides = h.server.get('CalendarEvent', 'a', id)!.recurrenceOverrides as Overrides; + expect(overrides['2026-09-29T09:00:00']).toMatchObject({ description: '', locations: { locB: { name: '' } } }); + }); +}); diff --git a/src/device-sync/__tests__/calendar/exceptions.test.ts b/src/device-sync/__tests__/calendar/exceptions.test.ts index 540f73a6..82007b54 100644 --- a/src/device-sync/__tests__/calendar/exceptions.test.ts +++ b/src/device-sync/__tests__/calendar/exceptions.test.ts @@ -103,6 +103,49 @@ describe('calendar planner: recurrence edits made on the device', () => { expect(h.row(x.eventId)).toBeUndefined(); }); + it('clears an occurrence\'s own description and location instead of letting the series\' come back', async () => { + const event = weekly(); + event.description = 'Daily agenda'; + event.recurrenceOverrides!['2026-10-07T09:00:00'] = { + ...event.recurrenceOverrides!['2026-10-07T09:00:00'], + description: 'Special agenda', + locations: { locB: { '@type': 'Location', name: 'Room 2' } }, + }; + const { h } = await synced(event); + const x = (await h.local('bl'))!.exceptions[0]; + expect(h.row(x.eventId)).toMatchObject({ [Events.DESCRIPTION]: 'Special agenda', [Events.EVENT_LOCATION]: 'Room 2' }); + h.fake.user.updateEvent(x.eventId, { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null }); + const local = (await h.local('bl'))!; + const plan = calendarPlanner.planUpload(local, h.ctx); + const server = await accept(h, event, local, plan); + expect(h.row(x.eventId)).toMatchObject({ [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null, [Events.DIRTY]: 0 }); + expect(calendarPlanner.planDownload(server, (await h.local('bl'))!, h.ctx).effect).toBe('none'); + // Without a value of its own the override would show the series' "Daily agenda" and "Room 1" again, so it + // gets the empty ones Stalwart keeps (an empty map would read back as no locations, i.e. the series' ones). + expect(patchOf(plan).patch).toEqual({ + 'recurrenceOverrides/2026-10-07T09:00:00/description': '', + 'recurrenceOverrides/2026-10-07T09:00:00/locations': { locB: { '@type': 'Location', name: '' } }, + }); + }); + + it('removes an occurrence\'s own description and location when the series has none to show instead', async () => { + const event = weekly(); + delete event.description; + delete event.locations; + event.recurrenceOverrides!['2026-10-07T09:00:00'] = { + ...event.recurrenceOverrides!['2026-10-07T09:00:00'], + description: 'Special agenda', + locations: { locB: { '@type': 'Location', name: 'Room 2' } }, + }; + const { h } = await synced(event); + const x = (await h.local('bl'))!.exceptions[0]; + h.fake.user.updateEvent(x.eventId, { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null }); + expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({ + 'recurrenceOverrides/2026-10-07T09:00:00/description': null, + 'recurrenceOverrides/2026-10-07T09:00:00/locations': null, + }); + }); + it('patches an existing override one level deep, never deeper', async () => { const { h } = await synced(weekly()); const x = (await h.local('bl'))!.exceptions[0]; diff --git a/src/device-sync/calendar/upload.ts b/src/device-sync/calendar/upload.ts index d312f3b8..fb877a51 100644 --- a/src/device-sync/calendar/upload.ts +++ b/src/device-sync/calendar/upload.ts @@ -454,10 +454,10 @@ function exceptionChanges( break; case 'description': if (x.baseline && isTruncatedBaseline(x.baseline.cells[Events.DESCRIPTION])) continue; - values.description = descriptionValue(cells); + values.description = descriptionValue(cells) ?? clearedInOverride('description', instance, shadow); break; case 'location': - values.locations = locationChanges(instance, cells[Events.EVENT_LOCATION], ctx.mintKey).locations; + values.locations = locationChanges(instance, cells[Events.EVENT_LOCATION], ctx.mintKey).locations ?? clearedInOverride('location', instance, shadow); break; case 'status': values.status = statusFromDevice(cells[Events.STATUS]); @@ -530,6 +530,22 @@ function isEmptyUnit(unit: string, cells: Row): boolean { return v === null || v === undefined || v === ''; } +/** + * A description or location cleared on an existing override. An override + * without one of its own shows the series' again, so while the series has + * one the override gets the empty value Stalwart keeps: the empty text, or a + * location without a name (an empty map writes no LOCATION line and reads + * back as none of its own). Null, a clean removal, when nothing would come + * back. + */ +function clearedInOverride(unit: 'description' | 'location', instance: CalendarEventWire, master: CalendarEventWire): unknown { + if (unit === 'description') return typeof master.description === 'string' && master.description ? '' : null; + const series = Object.keys(master.locations ?? {}); + if (!series.length) return null; + const key = Object.keys(instance.locations ?? {})[0] ?? series[0]; + return { [key]: { '@type': 'Location', name: '' } }; +} + /** * A new override: the changes plus the details Stalwart needs on it (title, * locations, participants, sequence, …; `withNewOverrideDetails`), minus what