fix: keep alert edits on events that use default alerts on Stalwart 1.0

While an event or task uses its calendar's default alerts, Stalwart 1.0
shows those defaults in `alerts` instead of the item's own, and drops
alerts under the default alerts' ids on save. The event editor renamed
every alert to alert1..N and sent them on every save, with the defaults
left on, so on 1.0 an alert change never showed up again, and every
save stored hidden copies of the defaults.

- send `alerts` only when the user changed them; untouched alerts keep
  their id and stay exactly as they were
- when the alerts of an item that uses the defaults change, turn the
  defaults off for it (`useDefaultAlerts: false`, as the native app
  does) and give its alerts ids of their own, in the event and the
  task editor

The local reminder engine reads either the calendar's defaults or the
item's own alerts, never both, so the defaults 1.0 fills in do not
fire twice.
This commit is contained in:
Linus Rath
2026-09-25 19:36:23 +02:00
parent 19c2dfb2b4
commit d6d8cd0881
7 changed files with 363 additions and 39 deletions
@@ -80,3 +80,48 @@ describe('TaskModal alert preservation (#504)', () => {
expect(Object.keys(patch.alerts!)).toEqual(['a-abs']);
});
});
// Stalwart 1.0 shows a task's calendar defaults in `alerts`, under the
// default alerts' ids, while `useDefaultAlerts` is on, and drops alerts with
// those ids on save.
describe('TaskModal alerts of a task that uses the calendar defaults', () => {
const defaultsTask = () => ({
...makeTask(),
useDefaultAlerts: true,
alerts: {
'default-15': {
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' },
action: 'display',
acknowledged: null,
relatedTo: null,
},
},
} as unknown as CalendarTask);
it('turns the defaults off and moves the alerts to ids of their own when the alert changes on Stalwart 1.0', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TaskModal task={defaultsTask()} calendars={calendars} onSave={onSave} onClose={vi.fn()} />);
fireEvent.change(screen.getByDisplayValue('tasks.alert_15min'), { target: { value: '60' } });
fireEvent.click(screen.getByText('tasks.save'));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
const patch = onSave.mock.calls[0][0] as Partial<CalendarTask>;
expect(patch.useDefaultAlerts).toBe(false);
expect(Object.keys(patch.alerts!)).toEqual(['alert1']);
expect(patch.alerts!.alert1.trigger).toMatchObject({ offset: '-PT60M' });
});
it('leaves the defaults alone when the alert was not touched', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TaskModal task={defaultsTask()} calendars={calendars} onSave={onSave} onClose={vi.fn()} />);
fireEvent.click(screen.getByText('tasks.save'));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
const patch = onSave.mock.calls[0][0] as Partial<CalendarTask>;
expect(patch).not.toHaveProperty('alerts');
expect(patch).not.toHaveProperty('useDefaultAlerts');
});
});
+88 -38
View File
@@ -24,6 +24,7 @@ import {
buildParticipantMap,
} from "@/lib/calendar-participants";
import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability";
import { withOwnAlertIds } from "@/lib/calendar-alerts";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
@@ -115,10 +116,12 @@ type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "cu
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
interface AlertRow {
export interface AlertRow {
id: string;
value: number;
unit: AlertUnit;
/** The event alert the row shows, and what the row read when loaded. */
source?: { alertId: string; value: number; unit: AlertUnit };
}
let alertRowSeq = 0;
@@ -159,6 +162,82 @@ function offsetToAlertRow(offset: string): AlertRow | null {
return null;
}
/**
* The editor rows for an event's alerts, and the alerts the rows cannot
* show (absolute triggers, email actions, unusual offsets), which a save
* keeps as they are.
*/
export function alertRowsOf(alerts: CalendarEvent["alerts"] | undefined): {
rows: AlertRow[];
preserved: Record<string, CalendarEventAlert>;
} {
const rows: AlertRow[] = [];
const preserved: Record<string, CalendarEventAlert> = {};
for (const [id, alert] of Object.entries(alerts ?? {})) {
const row = alert.trigger["@type"] === "OffsetTrigger" && alert.action === "display"
? offsetToAlertRow(alert.trigger.offset)
: null;
if (!row) {
preserved[id] = alert;
continue;
}
rows.push({ ...row, source: { alertId: id, value: row.value, unit: row.unit } });
}
return { rows, preserved };
}
/**
* What a save sends for the alerts: nothing when they are as the event had
* them. Rows the user left alone keep their alert as it was, under its id;
* new and changed rows become new alerts.
*
* On an event that uses its calendar's default alerts, a change turns them
* off for the event, as the native app does: while `useDefaultAlerts` is on,
* Stalwart 1.0 fills the defaults into `alerts` and shows those instead of
* the event's own, so a change would not stick. The alerts then get ids of
* their own (withOwnAlertIds), away from the event's and the calendar's
* default ids (`reservedIds`). 0.16 stores the alerts either way.
*/
export function buildAlertsPatch(
event: Pick<CalendarEvent, "alerts" | "useDefaultAlerts"> | null | undefined,
rows: AlertRow[],
preserved: Record<string, CalendarEventAlert>,
reservedIds: Iterable<string> = [],
): Partial<Pick<CalendarEvent, "alerts" | "useDefaultAlerts">> {
const original = event?.alerts ?? {};
const kept: Record<string, CalendarEventAlert> = { ...preserved };
const added: CalendarEventAlert[] = [];
for (const row of rows) {
const offset = alertRowToOffset(row);
if (offset === null) continue;
const { source } = row;
if (source && row.value === source.value && row.unit === source.unit && original[source.alertId]) {
kept[source.alertId] = original[source.alertId];
continue;
}
added.push({
"@type": "Alert",
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
action: "display",
acknowledged: null,
relatedTo: null,
});
}
const keptAll = Object.keys(original).every((id) => id in kept);
if (added.length === 0 && keptAll) return {};
const taken = [...Object.keys(original), ...reservedIds];
const alerts = event?.useDefaultAlerts
? withOwnAlertIds([...Object.values(kept), ...added], taken)
: { ...kept, ...withOwnAlertIds(added, taken) };
const patch: Partial<Pick<CalendarEvent, "alerts" | "useDefaultAlerts">> = {
alerts: Object.keys(alerts).length > 0 ? alerts : null,
};
if (event?.useDefaultAlerts) patch.useDefaultAlerts = false;
return patch;
}
function formatAlertRowLabel(
row: { value: number; unit: AlertUnit },
t: ReturnType<typeof useTranslations>
@@ -386,24 +465,11 @@ export function EventModal({
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
[customRule, t, locale]
);
// Alerts this UI can't represent survive a save as they are.
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
if (!event?.alerts) return [];
const rows: AlertRow[] = [];
for (const [id, alert] of Object.entries(event.alerts)) {
// Preserve alerts we can't represent in this UI (absolute triggers,
// email actions, offsets with non-canonical shapes) so they survive a save.
if (alert.trigger["@type"] !== "OffsetTrigger" || alert.action !== "display") {
preservedAlertsRef.current[id] = alert;
continue;
}
const row = offsetToAlertRow(alert.trigger.offset);
if (!row) {
preservedAlertsRef.current[id] = alert;
continue;
}
rows.push(row);
}
const { rows, preserved } = alertRowsOf(event?.alerts);
preservedAlertsRef.current = preserved;
return rows;
});
@@ -594,26 +660,10 @@ export function EventModal({
if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null;
}
const builtAlerts: Record<string, CalendarEventAlert> = { ...preservedAlertsRef.current };
let alertIdx = 0;
for (const row of alertRows) {
const offset = alertRowToOffset(row);
if (offset === null) continue;
let key = `alert${++alertIdx}`;
while (key in builtAlerts) key = `alert${++alertIdx}`;
builtAlerts[key] = {
"@type": "Alert",
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
action: "display",
acknowledged: null,
relatedTo: null,
};
}
if (Object.keys(builtAlerts).length > 0) {
data.alerts = builtAlerts;
} else if (event && event.alerts && Object.keys(event.alerts).length > 0) {
data.alerts = null;
}
const defaultAlertIds = calendars
.filter((c) => c.id === calendarId || event?.calendarIds?.[c.id])
.flatMap((c) => [...Object.keys(c.defaultAlertsWithTime ?? {}), ...Object.keys(c.defaultAlertsWithoutTime ?? {})]);
Object.assign(data, buildAlertsPatch(event, alertRows, preservedAlertsRef.current, defaultAlertIds));
if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
const organizerEmail = currentUserEmails[0];
@@ -644,7 +694,7 @@ export function EventModal({
} finally {
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving]);
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving]);
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return;
+9 -1
View File
@@ -8,6 +8,7 @@ import { X, Trash2, CalendarDays, Bell, Flag } from "@/components/icons";
import { format, parseISO } from "date-fns";
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
import { withOwnAlertIds } from "@/lib/calendar-alerts";
interface TaskModalProps {
task?: CalendarTask | null;
@@ -134,7 +135,14 @@ export function TaskModal({
relatedTo: null,
};
}
data.alerts = Object.keys(merged).length > 0 ? merged : null;
// A task that uses its calendar's default alerts turns them off:
// Stalwart 1.0 shows the defaults in `alerts` instead of the task's
// own while they are on, and drops alerts under the default ids.
const alerts = task?.useDefaultAlerts
? withOwnAlertIds(Object.values(merged), Object.keys(task.alerts ?? {}))
: merged;
if (task?.useDefaultAlerts) data.useDefaultAlerts = false;
data.alerts = Object.keys(alerts).length > 0 ? alerts : null;
}
if (isEdit && task) {
+43
View File
@@ -5,10 +5,13 @@ import {
getEffectiveAlerts,
buildAlertKey,
getPendingAlerts,
getPendingTaskAlerts,
withOwnAlertIds,
} from '../calendar-alerts';
import type {
CalendarEvent,
CalendarEventAlert,
CalendarTask,
Calendar,
} from '@/lib/jmap/types';
@@ -421,3 +424,43 @@ describe('getPendingAlerts', () => {
expect(result[0].calendarName).toBeNull();
});
});
// Stalwart 1.0 fills a calendar's default alerts into `alerts` while
// `useDefaultAlerts` is on, under the default alerts' own ids (for tasks as
// well). 0.16 copied the defaults into `alerts` once, when the event was
// created. Either way each alert fires once.
describe('default alerts filled in by the server', () => {
const eventStart = new Date('2026-03-01T10:00:00Z').getTime();
const defaults = { 'default-5': makeAlert(), 'default-mail': makeAlert({ action: 'email' }) };
const calendars = [makeCalendar({ defaultAlertsWithTime: defaults })];
it('fires each default alert of an event once on Stalwart 1.0', () => {
const event = makeEvent({ useDefaultAlerts: true, alerts: { ...defaults } });
const pending = getPendingAlerts([event], calendars, new Set(), eventStart - 5 * 60 * 1000 + 1000);
expect(pending.map((p) => p.alertId)).toEqual(['default-5']);
});
it('fires each default alert of an event once on 0.16', () => {
// Copied in at create time, under ids of 0.16's own.
const event = makeEvent({ useDefaultAlerts: true, alerts: { copied: makeAlert() } });
const pending = getPendingAlerts([event], calendars, new Set(), eventStart - 5 * 60 * 1000 + 1000);
expect(pending.map((p) => p.alertId)).toEqual(['default-5']);
});
it('fires the default alerts filled into a task once on Stalwart 1.0', () => {
const task = {
id: 'task-1', '@type': 'Task', title: 'Pay rent', calendarIds: { 'cal-1': true },
due: '2026-03-01T10:00:00Z', progress: 'needs-action', useDefaultAlerts: true, alerts: { ...defaults },
} as unknown as CalendarTask;
const pending = getPendingTaskAlerts([task], calendars, new Set(), eventStart - 5 * 60 * 1000 + 1000);
expect(pending.map((p) => p.alertId)).toEqual(['default-5']);
});
});
describe('withOwnAlertIds', () => {
it('numbers the alerts past the ids that are taken', () => {
const [a, b] = [makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' } }), makeAlert()];
expect(withOwnAlertIds([a, b], ['alert1', 'default-5'])).toEqual({ alert2: a, alert3: b });
expect(withOwnAlertIds([], ['alert1'])).toEqual({});
});
});
+146
View File
@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest';
import { alertRowsOf, buildAlertsPatch, type AlertRow } from '@/components/calendar/event-modal';
import type { CalendarEvent, CalendarEventAlert } from '@/lib/jmap/types';
// What the event editor saves for the alerts. It used to rename every alert
// to alert1..N and send them on every save. On Stalwart 1.0 that loses alert
// edits on events that use their calendar's default alerts:
// - on get, while `useDefaultAlerts` is on, the server shows the calendar's
// defaults in `alerts` (under the default alerts' ids) instead of the
// event's own (calendar_event/get.rs, DefaultAlertsView::Replace);
// - on save, it drops every alert whose id is a default id
// (calendar_event/set.rs, strip_default_alerts).
// 0.16 copied the defaults into the event once, at create, and stores
// whatever is sent.
const offsetAlert = (offset: string, extra: Partial<CalendarEventAlert> = {}): CalendarEventAlert => ({
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset, relativeTo: 'start' },
action: 'display',
acknowledged: null,
relatedTo: null,
...extra,
});
type Stored = { own: Record<string, CalendarEventAlert>; useDefaultAlerts: boolean };
type Patch = Partial<Pick<CalendarEvent, 'alerts' | 'useDefaultAlerts'>>;
const DEFAULTS = { 'default-15': offsetAlert('-PT15M') };
/** What Stalwart 1.0 answers for the event's alerts. */
function get10(stored: Stored): Pick<CalendarEvent, 'alerts' | 'useDefaultAlerts'> {
return { useDefaultAlerts: stored.useDefaultAlerts, alerts: stored.useDefaultAlerts ? { ...DEFAULTS } : { ...stored.own } };
}
/** How Stalwart 1.0 stores an update of the event's alerts. */
function update10(stored: Stored, patch: Patch): Stored {
const viewDefaults: Record<string, CalendarEventAlert> = stored.useDefaultAlerts ? DEFAULTS : {};
let alerts = { ...stored.own, ...viewDefaults };
if ('alerts' in patch) alerts = { ...(patch.alerts ?? {}) };
const useDefaultAlerts = 'useDefaultAlerts' in patch ? !!patch.useDefaultAlerts : stored.useDefaultAlerts;
const newDefaults: Record<string, CalendarEventAlert> = useDefaultAlerts ? DEFAULTS : {};
for (const id of Object.keys(alerts)) {
if (id in viewDefaults || id in newDefaults) delete alerts[id];
}
return { own: alerts, useDefaultAlerts };
}
const offsets = (alerts: CalendarEvent['alerts']) => Object.values(alerts ?? {})
.map((a) => (a.trigger as { offset: string }).offset)
.sort();
/** The editor's rows for `alerts`, with `edit` applied to them. */
function edit(event: Pick<CalendarEvent, 'alerts' | 'useDefaultAlerts'>, change: (rows: AlertRow[]) => AlertRow[]): Patch {
const { rows, preserved } = alertRowsOf(event.alerts);
return buildAlertsPatch(event, change(rows), preserved, Object.keys(DEFAULTS));
}
const newRow = (value: number, unit: AlertRow['unit']): AlertRow => ({ id: `new-${value}-${unit}`, value, unit });
describe('saving the alerts of an event that uses its calendar’s default alerts', () => {
it('sends nothing when they were not touched, on Stalwart 1.0', () => {
expect(edit(get10({ own: {}, useDefaultAlerts: true }), (rows) => rows)).toEqual({});
});
it('keeps a changed alert on Stalwart 1.0 by turning the defaults off', () => {
let stored: Stored = { own: {}, useDefaultAlerts: true };
const patch = edit(get10(stored), ([row]) => [{ ...row, value: 30 }]);
expect(patch.useDefaultAlerts).toBe(false);
stored = update10(stored, patch);
expect(offsets(get10(stored).alerts)).toEqual(['-PT30M']);
// What was sent before: the same alerts renamed, with the defaults left
// on. Stalwart 1.0 keeps showing the defaults instead.
const before = update10({ own: {}, useDefaultAlerts: true }, { alerts: { alert1: offsetAlert('-PT30M') } });
expect(offsets(get10(before).alerts)).toEqual(['-PT15M']);
});
it('keeps an untouched default next to a new alert on Stalwart 1.0, under ids of their own', () => {
let stored: Stored = { own: {}, useDefaultAlerts: true };
const patch = edit(get10(stored), (rows) => [...rows, newRow(1, 'hours')]);
expect(Object.keys(patch.alerts ?? {})).not.toContain('default-15');
stored = update10(stored, patch);
expect(offsets(get10(stored).alerts)).toEqual(['-PT15M', '-PT1H']);
});
it('removes every alert on Stalwart 1.0 when the user removes them', () => {
let stored: Stored = { own: {}, useDefaultAlerts: true };
const patch = edit(get10(stored), () => []);
expect(patch).toEqual({ alerts: null, useDefaultAlerts: false });
stored = update10(stored, patch);
expect(get10(stored).alerts).toEqual({});
});
it('turns the defaults off on 0.16 too, where the event holds its own copy of them', () => {
// 0.16 copied the calendar's defaults into the event at create time.
const event = { useDefaultAlerts: true, alerts: { copied: offsetAlert('-PT15M') } };
const patch = edit(event, ([row]) => [{ ...row, value: 45 }]);
expect(patch).toEqual({ useDefaultAlerts: false, alerts: { alert1: offsetAlert('-PT45M') } });
});
});
describe('saving the alerts of an event with alerts of its own', () => {
const event = {
useDefaultAlerts: false,
alerts: {
a1: offsetAlert('-PT15M'),
// Before the end, which the editor cannot show exactly.
a2: offsetAlert('PT10M', { trigger: { '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'end' } }),
abs: { ...offsetAlert('-PT1H'), trigger: { '@type': 'AbsoluteTrigger', when: '2026-09-10T08:00:00Z' } },
} as Record<string, CalendarEventAlert>,
};
it('sends nothing when they were not touched', () => {
expect(edit(event, (rows) => rows)).toEqual({});
});
it('keeps untouched alerts as they were, under their ids, and adds new ones under new ids', () => {
const patch = edit(event, (rows) => [...rows, newRow(2, 'days')]);
expect(patch).not.toHaveProperty('useDefaultAlerts');
expect(patch.alerts).toEqual({
a1: event.alerts.a1,
a2: event.alerts.a2,
abs: event.alerts.abs,
alert1: offsetAlert('-P2D'),
});
});
it('sends the remaining alerts when one is removed, and null when all are', () => {
expect(edit(event, (rows) => rows.filter((row) => row.source?.alertId !== 'a1')).alerts)
.toEqual({ a2: event.alerts.a2, abs: event.alerts.abs });
const onlyRows = { useDefaultAlerts: false, alerts: { a1: offsetAlert('-PT15M') } };
expect(edit(onlyRows, () => [])).toEqual({ alerts: null });
});
});
describe('saving the alerts of a new event', () => {
it('sends the rows as new alerts, and nothing without rows', () => {
expect(buildAlertsPatch(null, [newRow(10, 'minutes')], {})).toEqual({ alerts: { alert1: offsetAlert('-PT10M') } });
expect(buildAlertsPatch(null, [], {})).toEqual({});
});
});
+30
View File
@@ -69,6 +69,12 @@ export function computeFireTime(
return baseTime + offsetMs;
}
/**
* The alerts an event fires: the calendar's default alerts while the event
* uses them, its own otherwise - never both. Stalwart 1.0 also fills the
* defaults into the `alerts` of such an event, under the default alerts'
* ids; reading one or the other keeps each alert firing once.
*/
export function getEffectiveAlerts(
event: CalendarEvent,
calendars: Calendar[]
@@ -94,6 +100,30 @@ export function buildAlertKey(eventId: string, alertId: string, fireTimeMs: numb
return `${eventId}:${alertId}:${fireTimeMs}`;
}
/**
* `alerts` under new ids (alert1, alert2, ...), none of them in `taken`.
*
* For an event or task that turns its calendar's default alerts off: while
* `useDefaultAlerts` is on, Stalwart 1.0 shows the defaults in `alerts`
* under the default alerts' ids, and on save it drops every alert whose id
* is one of those. Alerts the user keeps have to be saved under ids of their
* own, or they vanish with the defaults.
*/
export function withOwnAlertIds(
alerts: CalendarEventAlert[],
taken: Iterable<string>,
): Record<string, CalendarEventAlert> {
const used = new Set(taken);
const result: Record<string, CalendarEventAlert> = {};
let n = 0;
for (const alert of alerts) {
let id: string;
do id = `alert${++n}`; while (used.has(id));
result[id] = alert;
}
return result;
}
export function getPendingAlerts(
events: CalendarEvent[],
calendars: Calendar[],
+2
View File
@@ -848,6 +848,8 @@ export interface CalendarTask {
created: string | null;
updated: string;
recurrenceRules: CalendarRecurrenceRule[] | null;
// While on, Stalwart 1.0 fills the calendar's default alerts into `alerts`.
useDefaultAlerts?: boolean;
alerts: Record<string, CalendarEventAlert> | null;
relatedTo: Record<string, CalendarRelation> | null;
}