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.
This commit is contained in:
Linus Rath
2026-09-27 07:10:47 +02:00
parent 49c5d05c30
commit 232e892328
3 changed files with 108 additions and 2 deletions
@@ -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<typeof createHarness>[0] = {}): Harness {
const h = createHarness(options);
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
return h;
}
type Overrides = Record<string, Record<string, unknown>>;
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: '' } } });
});
});
@@ -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];
+18 -2
View File
@@ -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