feat: show when someone invites you to, changes or cancels a calendar event

This commit is contained in:
Linus Rath
2026-10-31 14:28:18 +01:00
parent e6a89ad07a
commit d663a5e282
5 changed files with 588 additions and 0 deletions
@@ -0,0 +1,171 @@
/**
* Live CalendarEventNotification checks against a real Stalwart.
*
* Opt-in like live-stalwart-calendar.test.ts: skipped unless
* `BULWARK_LIVE_JMAP`, `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` (same
* password) are set. The second account invites the first one to an event in
* 2027 and then cancels it; the first one reads the notifications the server
* keeps for that and acknowledges them. Only the notifications of this run
* are destroyed, and the event carries the run's marker and is removed from
* both accounts afterwards.
*
* BULWARK_LIVE_JMAP=http://10.10.10.62:8016 \
* BULWARK_LIVE_USER=e2e-perf@example.org \
* BULWARK_LIVE_USER2=e2e-tablet-land@example.org \
* BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-event-notifications.test.ts
*
* Verified against Stalwart 0.16.27 and 1.0.1.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { JMAPClient } from '../jmap-client';
import { CAPABILITIES } from '../types';
import {
destroyCalendarEventNotifications,
getCalendarEventNotifications,
type CalendarEventNotification,
} from '../calendar-event-notifications';
import { eventNotificationToast } from '../../lib/calendar-event-notifications';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
const USER2 = process.env.BULWARK_LIVE_USER2;
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
const liveTwo = SERVER && PASS && USER2 ? describe : describe.skip;
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
const RUN = `live-cen-${Date.now().toString(36)}`;
type Response = [string, Record<string, any>, string];
async function raw(c: JMAPClient, calls: [string, Record<string, unknown>, string][]): Promise<Response[]> {
return (await c.request(calls, USING)).methodResponses as Response[];
}
async function waitFor<T>(read: () => Promise<T>, done: (value: T) => boolean, ms = 15_000): Promise<T> {
const until = Date.now() + ms;
let value = await read();
while (!done(value) && Date.now() < until) {
await new Promise((resolve) => setTimeout(resolve, 500));
value = await read();
}
return value;
}
/** Destroy every event of `c`'s account whose title carries the run's marker. */
async function removeRunEvents(c: JMAPClient): Promise<void> {
const res = await raw(c, [
['CalendarEvent/query', { accountId: c.accountId }, 'q'],
['CalendarEvent/get', {
accountId: c.accountId,
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
properties: ['id', 'title'],
}, 'g'],
]);
const ids = ((res.find((r) => r[2] === 'g')?.[1].list ?? []) as { id: string; title?: string }[])
.filter((e) => e.title?.includes(RUN))
.map((e) => e.id);
if (ids.length > 0) {
await raw(c, [['CalendarEvent/set', { accountId: c.accountId, destroy: ids, sendSchedulingMessages: false }, 'd']]);
}
}
const t = (_key: string, fallback?: string, params?: Record<string, string | number>) =>
(fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(params?.[k] ?? ''));
liveTwo('live Stalwart calendar event notifications', () => {
let other: JMAPClient;
let before = new Set<string>();
const ours = new Set<string>();
let organizerEventId = '';
let attendeeEventId = '';
const fresh = async (): Promise<CalendarEventNotification[]> =>
(await getCalendarEventNotifications()).filter((n) => !before.has(n.id));
beforeAll(async () => {
const mod = await import('../jmap-client');
const client = new JMAPClient();
await client.connect(SERVER!, USER, PASS);
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
other = new JMAPClient();
await other.connect(SERVER!, USER2!, PASS);
// Whatever is pending already belongs to someone else's run.
before = new Set((await getCalendarEventNotifications()).map((n) => n.id));
}, 60_000);
afterAll(async () => {
const leftover = (await getCalendarEventNotifications().catch(() => []))
.filter((n) => ours.has(n.id) || (!before.has(n.id) && n.event?.title?.includes(RUN)));
await destroyCalendarEventNotifications(leftover.map((n) => n.id)).catch(() => undefined);
await removeRunEvents(other).catch(() => undefined);
const mod = await import('../jmap-client');
await removeRunEvents(mod.jmapClient as unknown as JMAPClient).catch(() => undefined);
}, 60_000);
it('lists the invitation another account sent, with who sent it and the event’s title', async () => {
const cals = await raw(other, [['Calendar/get', { accountId: other.accountId, properties: ['id', 'isDefault'] }, '0']]);
const list = cals[0][1].list as { id: string; isDefault?: boolean }[];
const calendarId = (list.find((c) => c.isDefault) ?? list[0]).id;
const created = await raw(other, [['CalendarEvent/set', {
accountId: other.accountId,
create: {
inv: {
'@type': 'Event',
title: `${RUN} review`,
calendarIds: { [calendarId]: true },
start: '2027-06-08T10:00:00',
timeZone: 'Europe/Berlin',
duration: 'PT1H',
organizerCalendarAddress: `mailto:${USER2}`,
participants: {
org: {
'@type': 'Participant', calendarAddress: `mailto:${USER2}`, email: USER2,
roles: { owner: true, attendee: true }, participationStatus: 'accepted', kind: 'individual',
},
att: {
'@type': 'Participant', calendarAddress: `mailto:${USER}`, email: USER,
roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true, kind: 'individual',
},
},
},
},
sendSchedulingMessages: true,
}, '0']]);
organizerEventId = created[0][1].created?.inv?.id ?? '';
expect(organizerEventId, JSON.stringify(created[0][1].notCreated)).toBeTruthy();
const notes = await waitFor(fresh, (l) => l.some((n) => n.type === 'created'));
const invite = notes.find((n) => n.type === 'created');
expect(invite, JSON.stringify(notes)).toBeTruthy();
ours.add(invite!.id);
attendeeEventId = invite!.calendarEventId;
expect(invite!.changedBy?.email?.toLowerCase()).toBe(USER2!.toLowerCase());
expect(invite!.isDraft).toBe(false);
// 1.0 returns the event with the notification; 0.16 doesn't, and the
// title is read from the attendee's copy instead.
expect(invite!.event?.title).toBe(`${RUN} review`);
expect(eventNotificationToast(invite!, t)!.title).toContain(`invited you to "${RUN} review"`);
});
it('lists the cancellation, and forgets both once acknowledged', async () => {
expect(organizerEventId).toBeTruthy();
await raw(other, [['CalendarEvent/set', {
accountId: other.accountId,
destroy: [organizerEventId],
sendSchedulingMessages: true,
}, '0']]);
const notes = await waitFor(fresh, (l) => l.some((n) => n.type === 'destroyed' || (n.type === 'updated' && n.calendarEventId === attendeeEventId && !ours.has(n.id))));
const cancel = notes.find((n) => n.calendarEventId === attendeeEventId && !ours.has(n.id));
expect(cancel, JSON.stringify(notes)).toBeTruthy();
ours.add(cancel!.id);
const toast = eventNotificationToast(cancel!, t)!;
if (cancel!.type === 'destroyed') expect(toast.type).toBe('warning');
await destroyCalendarEventNotifications([...ours]);
const after = (await getCalendarEventNotifications()).map((n) => n.id);
for (const id of ours) expect(after).not.toContain(id);
// Nobody else's notifications went with them.
for (const id of before) expect(after).toContain(id);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import { batched } from './jmap-result';
// CalendarEventNotification (draft-ietf-jmap-calendars §7): the server
// records every invitation, update and cancellation another participant made
// to an event this user takes part in. Ported from the webmail's
// getCalendarEventNotifications / destroyCalendarEventNotifications in
// lib/jmap/client.ts.
export interface CalendarEventNotification {
id: string;
created: string;
changedBy?: {
name?: string | null;
email?: string | null;
principalId?: string | null;
} | null;
comment?: string | null;
type: 'created' | 'updated' | 'destroyed';
calendarEventId: string;
isDraft?: boolean;
/** The event as it is now; 1.0 returns it, 0.16 leaves it out. */
event?: { title?: string | null } | null;
}
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
/**
* Pending event notifications, oldest first. `event` is asked for its title
* only; Stalwart 0.16 doesn't return it, so the titles it lacks are read from
* the events themselves (a cancelled event is gone and stays untitled).
* Throws when the server refuses the listing.
*/
export async function getCalendarEventNotifications(): Promise<CalendarEventNotification[]> {
const accountId = jmapClient.accountId;
const response = await jmapClient.request([
['CalendarEventNotification/query', {
accountId,
sort: [{ property: 'created', isAscending: true }],
}, '0'],
['CalendarEventNotification/get', {
accountId,
'#ids': { resultOf: '0', name: 'CalendarEventNotification/query', path: '/ids' },
properties: ['id', 'created', 'changedBy', 'comment', 'type', 'calendarEventId', 'isDraft', 'event'],
}, '1'],
], USING);
const responses = (response.methodResponses ?? []) as [string, any, string][];
const getResp = responses.find((r) => r[0] === 'CalendarEventNotification/get');
if (!getResp) {
const error = responses.find((r) => r[0] === 'error')?.[1] as { description?: string } | undefined;
throw new Error(error?.description || 'Failed to load calendar event notifications');
}
const list = (getResp[1]?.list ?? []) as CalendarEventNotification[];
await fillMissingTitles(accountId, list);
return list;
}
async function fillMissingTitles(accountId: string, list: CalendarEventNotification[]): Promise<void> {
const ids = Array.from(new Set(
list.filter((n) => !n.isDraft && n.type !== 'destroyed' && !n.event?.title && n.calendarEventId)
.map((n) => n.calendarEventId),
));
if (ids.length === 0) return;
try {
const response = await jmapClient.request(
[['CalendarEvent/get', { accountId, ids, properties: ['id', 'title'] }, '0']],
USING,
);
const [method, result] = response.methodResponses?.[0] ?? [];
if (method !== 'CalendarEvent/get') return;
const titles = new Map<string, string>();
for (const event of (result?.list ?? []) as { id: string; title?: string | null }[]) {
if (event.title) titles.set(event.id, event.title);
}
for (const n of list) {
const title = titles.get(n.calendarEventId);
if (title && !n.event?.title) n.event = { ...(n.event ?? {}), title };
}
} catch {
// Untitled it is.
}
}
/** Acknowledges (destroys) event notifications that have been shown. */
export async function destroyCalendarEventNotifications(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const accountId = jmapClient.accountId;
for (const batch of batched(ids, jmapClient.getMaxObjectsInSet())) {
await jmapClient.request([
['CalendarEventNotification/set', { accountId, destroy: batch }, '0'],
], USING);
}
}
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const h = vi.hoisted(() => ({
client: { isConnected: true, accountId: 'u', currentSession: { apiUrl: 'https://mail.example/jmap/' } as unknown },
list: vi.fn(),
destroy: vi.fn(async (_ids: string[]) => undefined),
refresh: vi.fn(async () => undefined),
toast: { info: vi.fn(), warning: vi.fn(), success: vi.fn(), error: vi.fn(), dismiss: vi.fn() },
}));
vi.mock('react-native', () => ({ AppState: { addEventListener: vi.fn() } }));
vi.mock('../../api/jmap-client', () => ({ jmapClient: h.client }));
vi.mock('../../api/calendar-event-notifications', () => ({
getCalendarEventNotifications: h.list,
destroyCalendarEventNotifications: h.destroy,
}));
vi.mock('../../stores/auth-store', () => ({ useAuthStore: { subscribe: vi.fn() } }));
vi.mock('../../stores/calendar-store', () => ({ useCalendarStore: { getState: () => ({ refresh: h.refresh }) } }));
vi.mock('../../stores/toast-store', () => ({ toast: h.toast }));
vi.mock('../../stores/locale-store', () => ({
t: (_key: string, fallback?: string, params?: Record<string, string>) =>
(fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => params?.[k] ?? ''),
}));
vi.mock('../capabilities', () => ({ hasCalendarCapability: () => true }));
import fs from 'node:fs';
import path from 'node:path';
import {
eventNotificationToast,
pullCalendarEventNotifications,
resetCalendarEventNotifications,
startCalendarEventNotificationSync,
} from '../calendar-event-notifications';
import { dispatchStateChange } from '../state-change-bus';
import type { CalendarEventNotification } from '../../api/calendar-event-notifications';
// Ported from the webmail's components/layout/__tests__/calendar-event-notification-toaster.test.tsx
// and lib/__tests__/pending-notification-store.test.ts.
const t = (_key: string, fallback?: string, params?: Record<string, string | number>) =>
(fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(params?.[k] ?? ''));
function note(id: string, extra: Partial<CalendarEventNotification> = {}): CalendarEventNotification {
return {
id,
created: '2026-10-31T05:00:00Z',
changedBy: { name: 'Land', email: 'land@example.org', principalId: 't' },
comment: null,
type: 'updated',
calendarEventId: 'e1',
isDraft: false,
event: { title: 'Planning' },
...extra,
};
}
beforeEach(() => {
resetCalendarEventNotifications();
h.client.accountId = 'u';
h.list.mockReset();
h.destroy.mockClear();
h.refresh.mockClear();
for (const fn of Object.values(h.toast)) fn.mockClear();
});
describe('the toast for a calendar event notification', () => {
it('says who invited, changed or cancelled which event', () => {
expect(eventNotificationToast(note('1', { type: 'created' }), t)).toEqual({ type: 'info', title: 'Land invited you to "Planning"', message: undefined });
expect(eventNotificationToast(note('2'), t)).toEqual({ type: 'info', title: 'Land updated "Planning"', message: undefined });
expect(eventNotificationToast(note('3', { type: 'destroyed', comment: 'Sorry, ill' }), t))
.toEqual({ type: 'warning', title: 'Land cancelled "Planning"', message: 'Sorry, ill' });
});
it('falls back to the address, then "Someone", and to an untitled event', () => {
expect(eventNotificationToast(note('1', { changedBy: { email: 'x@example.org' }, event: null }), t)!.title)
.toBe('x@example.org updated "Untitled event"');
expect(eventNotificationToast(note('1', { changedBy: null }), t)!.title).toBe('Someone updated "Planning"');
});
it('shows nothing for a draft, the user’s own unsent change', () => {
expect(eventNotificationToast(note('1', { isDraft: true }), t)).toBeNull();
});
});
describe('pulling calendar event notifications', () => {
it('shows each new one, refreshes the calendar and acknowledges them on the server', async () => {
h.list.mockResolvedValue([note('a', { type: 'created' }), note('b', { type: 'destroyed' }), note('c', { isDraft: true })]);
await pullCalendarEventNotifications();
expect(h.toast.info).toHaveBeenCalledWith('Land invited you to "Planning"', undefined);
expect(h.toast.warning).toHaveBeenCalledWith('Land cancelled "Planning"', undefined);
expect(h.toast.info).toHaveBeenCalledTimes(1);
expect(h.refresh).toHaveBeenCalledTimes(1);
// Drafts are acknowledged too, as the webmail does.
expect(h.destroy).toHaveBeenCalledWith(['a', 'b', 'c']);
});
it('does not show one again when the server still lists it', async () => {
h.list.mockResolvedValue([note('a')]);
await pullCalendarEventNotifications();
await pullCalendarEventNotifications();
expect(h.toast.info).toHaveBeenCalledTimes(1);
expect(h.destroy).toHaveBeenCalledTimes(1);
});
it('does nothing when there is nothing new', async () => {
h.list.mockResolvedValue([]);
await pullCalendarEventNotifications();
expect(h.refresh).not.toHaveBeenCalled();
expect(h.destroy).not.toHaveBeenCalled();
});
it('shows nothing from the previous account after a switch during the fetch', async () => {
h.list.mockImplementation(async () => {
h.client.accountId = 'other';
return [note('a')];
});
await pullCalendarEventNotifications();
expect(h.toast.info).not.toHaveBeenCalled();
expect(h.destroy).not.toHaveBeenCalled();
});
it('pulls again when a push arrives during a pull', async () => {
let release!: () => void;
h.list.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve([note('a')]); }));
h.list.mockResolvedValueOnce([note('a'), note('b')]);
const first = pullCalendarEventNotifications();
const second = pullCalendarEventNotifications();
release();
await Promise.all([first, second]);
expect(h.list).toHaveBeenCalledTimes(2);
expect(h.toast.info).toHaveBeenCalledTimes(2);
});
it('keeps quiet when the server refuses the listing', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
h.list.mockRejectedValue(new Error('unknownMethod'));
await expect(pullCalendarEventNotifications()).resolves.toBeUndefined();
expect(h.toast.info).not.toHaveBeenCalled();
warn.mockRestore();
});
});
describe('when notifications are pulled', () => {
it('pulls on a CalendarEventNotification push', async () => {
vi.useFakeTimers();
try {
h.list.mockResolvedValue([]);
startCalendarEventNotificationSync();
await vi.advanceTimersByTimeAsync(1500);
h.list.mockClear();
h.list.mockResolvedValue([note('p', { type: 'created' })]);
dispatchStateChange({ '@type': 'StateChange', changed: { u: { CalendarEventNotification: 's2' } } });
await vi.advanceTimersByTimeAsync(1500);
expect(h.list).toHaveBeenCalledTimes(1);
expect(h.toast.info).toHaveBeenCalledWith('Land invited you to "Planning"', undefined);
} finally {
vi.useRealTimers();
}
});
it('starts with the reminder sync, which the app starts at launch', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../calendar-notifications.ts'), 'utf8');
const start = source.slice(source.indexOf('export function startCalendarNotificationSync'));
expect(start).toMatch(/syncStarted = true;[\s\S]*startCalendarEventNotificationSync\(\);/);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { AppState } from 'react-native';
import { jmapClient } from '../api/jmap-client';
import {
destroyCalendarEventNotifications,
getCalendarEventNotifications,
type CalendarEventNotification,
} from '../api/calendar-event-notifications';
import { useAuthStore } from '../stores/auth-store';
import { useCalendarStore } from '../stores/calendar-store';
import { t as translate } from '../stores/locale-store';
import { toast } from '../stores/toast-store';
import { hasCalendarCapability } from './capabilities';
import { onStateChangeType } from './state-change-bus';
// Someone invited the user, changed or cancelled an event the user takes
// part in: the server keeps a CalendarEventNotification for it. They are
// pulled at sign-in, on every `CalendarEventNotification` push and when the
// app comes back to the foreground, shown as toasts, the events in view are
// refreshed so the change shows, and the notifications are acknowledged,
// which destroys them on the server. Ported from the webmail's
// stores/calendar-event-notification-store.ts,
// lib/pending-notification-store.ts and
// components/layout/calendar-event-notification-toaster.tsx.
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
export interface EventNotificationToast {
type: 'info' | 'warning';
title: string;
message?: string;
}
/** The toast for one notification; null for a draft (the user's own unsent change). */
export function eventNotificationToast(n: CalendarEventNotification, t: Translate): EventNotificationToast | null {
if (n.isDraft) return null;
const kind = n.type === 'created' ? 'invited' : n.type === 'destroyed' ? 'cancelled' : 'updated';
const params = {
name: n.changedBy?.name || n.changedBy?.email || t('calendar_event_notifications.someone', 'Someone'),
title: n.event?.title || t('calendar_event_notifications.untitled', 'Untitled event'),
};
const title = kind === 'invited'
? t('calendar_event_notifications.invited', '{name} invited you to "{title}"', params)
: kind === 'cancelled'
? t('calendar_event_notifications.cancelled', '{name} cancelled "{title}"', params)
: t('calendar_event_notifications.updated', '{name} updated "{title}"', params);
return { type: kind === 'cancelled' ? 'warning' : 'info', title, message: n.comment || undefined };
}
// Ids already shown in this session; a pull racing an acknowledgement must
// not show them again.
const seen = new Set<string>();
let seenFor: string | null = null;
let inFlight: Promise<void> | null = null;
let again = false;
let syncStarted = false;
let pullTimer: ReturnType<typeof setTimeout> | null = null;
function sessionKey(): string | null {
try {
return `${jmapClient.currentSession?.apiUrl ?? ''}|${jmapClient.accountId}`;
} catch {
return null;
}
}
/**
* Fetch the pending notifications, show the new ones, refresh the calendar
* and acknowledge them. Concurrent calls coalesce; a push arriving while a
* pull runs pulls again afterwards. Never rejects.
*/
export function pullCalendarEventNotifications(): Promise<void> {
if (inFlight) {
again = true;
return inFlight;
}
inFlight = (async () => {
try {
do {
again = false;
await pullOnce();
} while (again);
} finally {
inFlight = null;
}
})();
return inFlight;
}
async function pullOnce(): Promise<void> {
if (!jmapClient.isConnected || !hasCalendarCapability()) return;
const key = sessionKey();
if (!key) return;
if (key !== seenFor) {
seen.clear();
seenFor = key;
}
let list: CalendarEventNotification[];
try {
list = await getCalendarEventNotifications();
} catch (error) {
console.warn('[calendar] Failed to fetch calendar event notifications:', error);
return;
}
// Another account by now: its own pull shows its own.
if (sessionKey() !== key) return;
const fresh = list.filter((n) => !seen.has(n.id));
if (fresh.length === 0) return;
for (const n of fresh) {
seen.add(n.id);
const shown = eventNotificationToast(n, translate as Translate);
if (shown) toast[shown.type](shown.title, shown.message);
}
void useCalendarStore.getState().refresh().catch(() => undefined);
try {
await destroyCalendarEventNotifications(fresh.map((n) => n.id));
} catch (error) {
// Worst case they are shown again after a restart.
console.warn('[calendar] Failed to acknowledge calendar event notifications:', error);
}
}
function pullSoon(): void {
if (pullTimer) clearTimeout(pullTimer);
// Sign-in fans out its own requests first; a burst of pushes is one pull.
pullTimer = setTimeout(() => {
pullTimer = null;
void pullCalendarEventNotifications();
}, 1000);
}
/** Pull event notifications for the rest of the session. Idempotent. */
export function startCalendarEventNotificationSync(): void {
if (syncStarted) return;
syncStarted = true;
onStateChangeType('CalendarEventNotification', pullSoon);
useAuthStore.subscribe((state, prev) => {
if (state.session !== prev.session && state.session) pullSoon();
});
// Pushes stop while the app is in the background.
AppState.addEventListener('change', (state) => {
if (state === 'active') pullSoon();
});
pullSoon();
}
/** Forget what was shown (tests). */
export function resetCalendarEventNotifications(): void {
seen.clear();
seenFor = null;
inFlight = null;
again = false;
if (pullTimer) clearTimeout(pullTimer);
pullTimer = null;
}
+3
View File
@@ -11,6 +11,7 @@ import { onStateChangeType } from './state-change-bus';
import { getUpcomingAlerts, type ScheduledAlert } from './calendar-alert-scheduler';
import type { CalendarReminderTarget } from '../navigation/pending-calendar-open';
import { onDeviceRemindersChange, remindedOnDevice } from '../device-sync/app/reminders';
import { startCalendarEventNotificationSync } from './calendar-event-notifications';
// Local reminders for calendar events and tasks. The webmail polls
// `getPendingAlerts` every minute while a tab is open; a phone is mostly
@@ -369,6 +370,8 @@ export function startCalendarNotificationSync(options?: { askPermission?: boolea
if (syncStarted) return;
syncStarted = true;
installForegroundHandler();
// Invitations, changes and cancellations others made (CalendarEventNotification).
startCalendarEventNotificationSync();
// Edits in the app, a refresh after a push, tasks loading. Moving the
// calendar to another window changes no reminder.
useCalendarStore.subscribe((state, prev) => {