diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts index 3482cac8..08193a09 100644 --- a/src/api/__tests__/live-stalwart-calendar.test.ts +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -41,6 +41,8 @@ import { editorTimesChanged, editorTimesFromEvent, eventTimeFieldsToSave } from import { participantFieldsToSave, participantLimitOf, seedAttendees, type Attendee } from '../../lib/calendar-participants'; import { expandRecurringEvents } from '../../lib/recurrence-expansion'; import { getEventStartDate } from '../../lib/calendar-utils'; +import { alertsToReminders, calendarDefaultAlerts, defaultAlertIdPrefix, remindersToDefaultAlerts } from '../../lib/calendar-alerts'; +import { getEffectiveAlerts } from '../../lib/calendar-alert-scheduler'; const SERVER = process.env.BULWARK_LIVE_JMAP; const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; @@ -698,6 +700,60 @@ live('live Stalwart calendar', () => { }, 60_000); }); + describe('a calendar\'s default reminders (#1209)', () => { + let defaultsCalendarId = ''; + let otherCalendarId = ''; + + afterAll(async () => { + const ids = [defaultsCalendarId, otherCalendarId].filter(Boolean); + if (ids.length > 0) { + await raw(client, [['Calendar/set', { accountId: client.accountId, destroy: ids, onDestroyRemoveEvents: true }, '0']]).catch(() => undefined); + } + }, 30_000); + + it('are set and cleared in the calendar and given to a new event that uses them', async () => { + await connectSingleton(); + const [[, made]] = await raw(client, [['Calendar/set', { + accountId: client.accountId, create: { c: { name: `${RUN} defaults`, color: '#3b82f6' } }, + }, '0']]); + defaultsCalendarId = made.created?.c?.id ?? ''; + expect(defaultsCalendarId, JSON.stringify(made.notCreated)).toBeTruthy(); + await useCalendarStore.getState().fetchCalendars(); + + // As the calendar editor saves them. + // Another calendar of the account with defaults under the plain ids + // the editor wrote before: 1.0 wants them unique in the account. + const plain = await raw(client, [['Calendar/set', { + accountId: client.accountId, + create: { o: { name: `${RUN} other defaults`, defaultAlertsWithTime: remindersToDefaultAlerts([{ minutesBefore: 5 }]) } }, + }, '0']]); + otherCalendarId = plain[0][1].created?.o?.id ?? ''; + const target = { id: defaultsCalendarId }; + await useCalendarStore.getState().updateCalendar(defaultsCalendarId, { + defaultAlertsWithTime: remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix(target, false)), + defaultAlertsWithoutTime: remindersToDefaultAlerts([{ minutesBefore: 60 * 24 }], {}, defaultAlertIdPrefix(target, true)), + }); + await useCalendarStore.getState().fetchCalendars(); + const cal = useCalendarStore.getState().calendars.find((c) => c.id === defaultsCalendarId)!; + expect(alertsToReminders(calendarDefaultAlerts(cal, false))).toEqual([{ minutesBefore: 15 }]); + expect(alertsToReminders(calendarDefaultAlerts(cal, true))).toEqual([{ minutesBefore: 60 * 24 }]); + + // As the event editor creates an event on the defaults. + const created = await useCalendarStore.getState().createEvent({ + '@type': 'Event', title: `${RUN} on defaults`, start: '2027-12-03T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', useDefaultAlerts: true, + } as Partial, defaultsCalendarId); + const [stored] = await getEvents([created.id]); + expect(stored.useDefaultAlerts).toBe(true); + const effective = getEffectiveAlerts(stored, useCalendarStore.getState().calendars) ?? {}; + expect(alertsToReminders(effective)).toEqual([{ minutesBefore: 15 }]); + + // Emptied. + await useCalendarStore.getState().updateCalendar(defaultsCalendarId, { defaultAlertsWithTime: remindersToDefaultAlerts([]) }); + const [[, after]] = await raw(client, [['Calendar/get', { accountId: client.accountId, ids: [defaultsCalendarId], properties: ['defaultAlertsWithTime'] }, '0']]); + expect(Object.keys(after.list[0].defaultAlertsWithTime ?? {})).toEqual([]); + }, 60_000); + }); + describe('a zero-length event (#837)', () => { it('ends at its start, stored without a duration or imported from an .ics', async () => { await connectSingleton(); diff --git a/src/api/calendar.ts b/src/api/calendar.ts index 1f52497a..1fd0fe14 100644 --- a/src/api/calendar.ts +++ b/src/api/calendar.ts @@ -1,6 +1,6 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; -import type { Calendar, CalendarEvent, CalendarRights } from './types'; +import type { Alert, Calendar, CalendarEvent, CalendarRights } from './types'; import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; import { formatWallClock, getEffectiveTimeZone, getTimeZoneOffsetMs } from '../lib/calendar-timezone'; import { SCAN_PROPERTIES, isTaskLikeObject, type ScannedCalendarObject } from '../lib/calendar-component-detection'; @@ -1295,6 +1295,9 @@ export interface CalendarUpdates { description?: string | null; sortOrder?: number; isSubscribed?: boolean; + // `{}` clears them: Stalwart refuses null for these maps. + defaultAlertsWithTime?: Record; + defaultAlertsWithoutTime?: Record; } // Rename / recolour / describe a calendar (Calendar/set update). diff --git a/src/components/calendar/CalendarEditSheet.tsx b/src/components/calendar/CalendarEditSheet.tsx index 420cfe8a..60b65a0b 100644 --- a/src/components/calendar/CalendarEditSheet.tsx +++ b/src/components/calendar/CalendarEditSheet.tsx @@ -10,11 +10,22 @@ import { TouchableWithoutFeedback, View, } from 'react-native'; -import { Check } from '../icons'; -import type { Calendar } from '../../api/types'; +import { Check, Plus, X } from '../icons'; +import type { Alert, Calendar } from '../../api/types'; +import { + REMINDER_PRESETS, + alertsToReminders, + calendarDefaultAlerts, + defaultAlertIdPrefix, + formatReminder, + preservedAlerts, + remindersToDefaultAlerts, + sameReminders, + type Reminder, +} from '../../lib/calendar-alerts'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useLocaleStore } from '../../stores/locale-store'; +import { useLocaleStore, type TranslateFn } from '../../stores/locale-store'; import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils'; import Button from '../Button'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; @@ -24,6 +35,79 @@ export interface CalendarEditValues { name: string; color: string; description: string; + /** Set only when the user changed them. */ + defaultAlertsWithTime?: Record; + defaultAlertsWithoutTime?: Record; +} + +/** One list of a calendar's default reminders: the times, a remove button each, presets to add. */ +function DefaultRemindersEditor({ + label, + reminders, + keptCount, + onChange, + styles, + c, + t, +}: { + label: string; + reminders: Reminder[]; + keptCount: number; + onChange: (next: Reminder[]) => void; + styles: ReturnType; + c: ThemePalette; + t: TranslateFn; +}) { + const [picking, setPicking] = React.useState(false); + const available = REMINDER_PRESETS.filter((p) => !reminders.some((r) => r.minutesBefore === p)); + return ( + + {label} + {reminders.length === 0 && keptCount === 0 && ( + {t('calendar.alerts.none', 'No reminders')} + )} + {reminders.map((r) => ( + + {formatReminder(r.minutesBefore, t)} + onChange(reminders.filter((x) => x.minutesBefore !== r.minutesBefore))} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('calendar.alerts.remove', 'Remove reminder')} + > + + + + ))} + {keptCount > 0 && ( + + {t('calendar.alerts.kept', "{count, plural, one {# more reminder that can't be edited here is kept} other {# more reminders that can't be edited here are kept}}", { count: keptCount })} + + )} + {available.length > 0 && ( + setPicking((v) => !v)} accessibilityRole="button"> + + {t('calendar.alerts.add', 'Add reminder')} + + )} + {picking && ( + + {available.map((preset) => ( + { + onChange([...reminders, { minutesBefore: preset }]); + setPicking(false); + }} + > + {formatReminder(preset, t)} + + ))} + + )} + + ); } interface CalendarEditSheetProps { @@ -47,15 +131,26 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend const [description, setDescription] = React.useState(''); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); + // The calendar's default reminders (#1209): the owner's to set, for an + // existing calendar. What the lists can't show is written back unchanged. + const editsDefaults = !!calendar && !calendar.isShared; + const initialTimed = React.useMemo(() => alertsToReminders(calendarDefaultAlerts(calendar, false)), [calendar]); + const initialAllDay = React.useMemo(() => alertsToReminders(calendarDefaultAlerts(calendar, true)), [calendar]); + const keptTimed = React.useMemo(() => preservedAlerts(calendarDefaultAlerts(calendar, false)), [calendar]); + const keptAllDay = React.useMemo(() => preservedAlerts(calendarDefaultAlerts(calendar, true)), [calendar]); + const [timedReminders, setTimedReminders] = React.useState([]); + const [allDayReminders, setAllDayReminders] = React.useState([]); React.useEffect(() => { if (!visible) return; setName(calendar?.name ?? ''); setColor(calendar ? getCalendarColor(calendar) : CALENDAR_COLOR_PALETTE[0]); setDescription(calendar?.description ?? ''); + setTimedReminders(initialTimed); + setAllDayReminders(initialAllDay); setError(null); setSaving(false); - }, [visible, calendar]); + }, [visible, calendar, initialTimed, initialAllDay]); const handleSave = async () => { const trimmed = name.trim(); @@ -63,7 +158,15 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend setSaving(true); setError(null); try { - await onSave({ name: trimmed, color, description: description.trim() }); + const values: CalendarEditValues = { name: trimmed, color, description: description.trim() }; + // Ids unique in the account: Stalwart 1.0 refuses one another calendar uses. + if (calendar && editsDefaults && !sameReminders(timedReminders, initialTimed)) { + values.defaultAlertsWithTime = remindersToDefaultAlerts(timedReminders, keptTimed, defaultAlertIdPrefix(calendar, false)); + } + if (calendar && editsDefaults && !sameReminders(allDayReminders, initialAllDay)) { + values.defaultAlertsWithoutTime = remindersToDefaultAlerts(allDayReminders, keptAllDay, defaultAlertIdPrefix(calendar, true)); + } + await onSave(values); onClose(); } catch (e) { setError(e instanceof Error ? e.message : t('calendar.notifications.event_error', 'Something went wrong')); @@ -120,6 +223,35 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend style={[styles.input, styles.multiline]} multiline /> + {editsDefaults && ( + <> + {t('calendar.management.default_alerts', 'Default reminders')} + + {t( + 'calendar.management.default_alerts_hint', + "New events in this calendar get these reminders unless you change them in the event. Changing them here doesn't change existing events. Email reminders are sent by the mail server.", + )} + + + + + )} {error ? {error} : null} @@ -185,5 +317,32 @@ function makeStyles(c: ThemePalette) { swatchActive: { borderWidth: 2, borderColor: c.text }, error: { ...typography.caption, color: c.error }, footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md }, + sectionTitle: { ...typography.bodyMedium, color: c.text, marginTop: spacing.md }, + muted: { ...typography.caption, color: c.textMuted }, + reminders: { gap: spacing.xs }, + reminderRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 36, + paddingHorizontal: spacing.md, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + reminderText: { ...typography.body, color: c.text }, + addRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingVertical: spacing.xs }, + addRowText: { ...typography.bodyMedium, color: c.primary }, + presets: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs }, + preset: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radius.full, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + presetText: { ...typography.caption, color: c.text }, }); } diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index f7ebebb2..bce7fbba 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -50,8 +50,10 @@ import { useCalendarLocale } from '../../lib/calendar-locale'; import { formatCalendarDate } from '../../lib/date-format'; import { alertsToReminders, + calendarDefaultAlerts, reminderFieldsToSave, preservedAlerts, + withOwnAlertIds, formatReminder, REMINDER_PRESETS, REMINDER_UNIT_MINUTES, @@ -228,6 +230,10 @@ export function EventModal({ const [customRule, setCustomRule] = React.useState(null); const [recurrenceEditorOpen, setRecurrenceEditorOpen] = React.useState(false); const [reminders, setReminders] = React.useState([]); + // A new event starts on its calendar's default reminders (#1209) until + // the user customizes them. + const [useDefaults, setUseDefaults] = React.useState(false); + const onDefaults = !event && useDefaults; // Alerts the picker can't show (absolute / end-relative / non-display) // ride along untouched so a save never drops them. const [preserved, setPreserved] = React.useState>({}); @@ -268,6 +274,7 @@ export function EventModal({ setCustomRule(detected === 'custom' ? event.recurrenceRules?.[0] ?? null : null); setRecurrenceEditorOpen(false); setReminders(alertsToReminders(event.alerts)); + setUseDefaults(false); setPreserved(preservedAlerts(event.alerts)); setSendInvitations(true); setCustomReminderOpen(false); @@ -286,6 +293,7 @@ export function EventModal({ setCustomRule(null); setRecurrenceEditorOpen(false); setReminders([]); + setUseDefaults(true); setPreserved({}); setSendInvitations(true); setCustomReminderOpen(false); @@ -369,7 +377,11 @@ export function EventModal({ recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'), // Untouched reminders are not sent: an event on its calendar's // default alerts keeps them. - ...reminderFieldsToSave(event, reminders, preserved, defaultAlertIds), + // A new event on its calendar's defaults: the server copies them + // into it (sending them as alerts too would double them). + ...(onDefaults + ? { useDefaultAlerts: true } + : reminderFieldsToSave(event, reminders, preserved, defaultAlertIds)), locations: clearedOr(buildLocations(location), 'locations'), virtualLocations: clearedOr(buildVirtualLocations(videoUrl), 'virtualLocations'), }; @@ -455,6 +467,18 @@ export function EventModal({ const availablePresets = REMINDER_PRESETS.filter( (p) => !reminders.some((r) => r.minutesBefore === p), ); + // The selected calendar's defaults, for an event with a time or all day. + const calendarDefaults = calendarDefaultAlerts(selectedCalendar, allDay); + const defaultReminders = alertsToReminders(calendarDefaults); + // Start the event's own reminders from the defaults, to change them. + const customizeReminders = () => { + setReminders(defaultReminders); + // Under ids of their own: Stalwart 1.0 drops an event's alert under a + // default alert's id. + const kept = preservedAlerts(calendarDefaults); + setPreserved(withOwnAlertIds(Object.values(kept), Object.keys(calendarDefaults))); + setUseDefaults(false); + }; return (
} label={t('calendar.alerts.title', 'Reminders')}> - {reminders.length > 0 && ( + {onDefaults && ( + + {t('calendar.alerts.calendar_defaults', 'Calendar defaults')} + {defaultReminders.length === 0 && ( + {t('calendar.alerts.none', 'No reminders')} + )} + {defaultReminders.map((r) => ( + + {formatReminder(r.minutesBefore, t)} + + ))} + + {t('calendar.alerts.customize', 'Customize')} + + + )} + {!onDefaults && !event && ( + { + setUseDefaults(true); + setReminderPickerOpen(false); + setCustomReminderOpen(false); + }} + accessibilityRole="button" + > + {t('calendar.alerts.use_defaults', 'Use calendar defaults')} + + )} + {!onDefaults && reminders.length > 0 && ( {reminders.map((r) => ( @@ -672,7 +725,7 @@ export function EventModal({ ))} )} - {availablePresets.length > 0 && ( + {!onDefaults && availablePresets.length > 0 && ( setReminderPickerOpen((v) => !v)} @@ -1035,6 +1088,7 @@ function makeStyles(c: ThemePalette) { paddingVertical: spacing.sm, }, addRowText: { ...typography.body, color: c.primary }, + defaultsCaption: { ...typography.caption, color: c.textMuted }, popover: { borderWidth: 1, diff --git a/src/lib/__tests__/calendar-alerts.test.ts b/src/lib/__tests__/calendar-alerts.test.ts index 3d13c640..624255d8 100644 --- a/src/lib/__tests__/calendar-alerts.test.ts +++ b/src/lib/__tests__/calendar-alerts.test.ts @@ -8,6 +8,10 @@ import { preservedAlerts, reminderFieldsToSave, withOwnAlertIds, + calendarDefaultAlerts, + defaultAlertIdPrefix, + remindersToDefaultAlerts, + sameReminders, } from '../calendar-alerts'; import { translate, type MessageParams } from '../../i18n'; import type { Alert } from '../../api/types'; @@ -164,3 +168,41 @@ describe('withOwnAlertIds', () => { expect(Object.keys(withOwnAlertIds([alert, alert], ['reminder-1', 'reminder-3']))).toEqual(['reminder-2', 'reminder-4']); }); }); + +describe('a calendar\'s default reminders (#1209)', () => { + const email = { '@type': 'Alert', action: 'email', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } as Alert; + + it('picks the defaults for timed or all-day events', () => { + const cal = { + defaultAlertsWithTime: { a: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } as Alert }, + defaultAlertsWithoutTime: null, + }; + expect(alertsToReminders(calendarDefaultAlerts(cal, false))).toEqual([{ minutesBefore: 15 }]); + expect(calendarDefaultAlerts(cal, true)).toEqual({}); + expect(calendarDefaultAlerts(undefined, false)).toEqual({}); + }); + + it('writes the defaults under ids of their own and keeps what the list cannot show', () => { + const alerts = remindersToDefaultAlerts([{ minutesBefore: 10 }, { minutesBefore: 60 * 24 }], { 'default-1': email }); + expect(alerts['default-1']).toBe(email); + expect(Object.keys(alerts).sort()).toEqual(['default-1', 'default-2', 'default-3']); + expect(alertsToReminders(alerts)).toEqual([{ minutesBefore: 60 * 24 }, { minutesBefore: 60 }, { minutesBefore: 10 }]); + // Emptied: a map Stalwart takes, not null. + expect(remindersToDefaultAlerts([])).toEqual({}); + }); + + it('gives each calendar and list ids no other calendar of the account uses (Stalwart 1.0)', () => { + const b = remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix({ id: 'b' }, false)); + const c = remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix({ id: 'acc-2:c', originalId: 'c' }, false)); + const bAllDay = remindersToDefaultAlerts([{ minutesBefore: 15 }], {}, defaultAlertIdPrefix({ id: 'b' }, true)); + const ids = [...Object.keys(b), ...Object.keys(c), ...Object.keys(bAllDay)]; + expect(new Set(ids).size).toBe(3); + expect(Object.keys(b)).toEqual(['cal-b-timed-1']); + }); + + it('tells a changed list from the same times in another order', () => { + expect(sameReminders([{ minutesBefore: 5 }, { minutesBefore: 15 }], [{ minutesBefore: 15 }, { minutesBefore: 5 }])).toBe(true); + expect(sameReminders([{ minutesBefore: 5 }], [{ minutesBefore: 10 }])).toBe(false); + expect(sameReminders([], [{ minutesBefore: 10 }])).toBe(false); + }); +}); diff --git a/src/lib/calendar-alerts.ts b/src/lib/calendar-alerts.ts index 2090a8c7..bd93e180 100644 --- a/src/lib/calendar-alerts.ts +++ b/src/lib/calendar-alerts.ts @@ -1,4 +1,4 @@ -import type { Alert, CalendarEvent } from '../api/types'; +import type { Alert, Calendar, CalendarEvent } from '../api/types'; import { formatMessage, type MessageParams } from '../i18n/format'; // A reminder, expressed as "minutes before the event start". 0 means "at the @@ -130,6 +130,57 @@ function reminderMinutes(reminders: Reminder[]): number[] { return [...new Set(reminders.map((r) => r.minutesBefore))].sort((a, b) => b - a); } +/** Whether two reminder lists remind at the same times. */ +export function sameReminders(a: Reminder[], b: Reminder[]): boolean { + const x = reminderMinutes(a); + const y = reminderMinutes(b); + return x.length === y.length && x.every((minutes, i) => minutes === y[i]); +} + +// ─── A calendar's default alerts (#1209) ───────────────── + +/** The alerts `calendar` gives an event with a time, or an all-day one, that uses its defaults. */ +export function calendarDefaultAlerts( + calendar: Pick | null | undefined, + allDay: boolean, +): Record { + return (allDay ? calendar?.defaultAlertsWithoutTime : calendar?.defaultAlertsWithTime) ?? {}; +} + +/** + * A calendar's default alerts as the editor's reminders set them, with the + * ones it can't show (`preserved`) kept as they were. Under ids of their own, + * `${idPrefix}-1` and on: Stalwart 1.0 refuses default alert ids that another + * calendar of the account uses ("Default alert ids must be unique across all + * calendars in the account."), so the prefix names the calendar and the + * list; it also shows them in an event that uses them under the same ids, + * where an event's own reminder-N alerts must not meet them. An emptied list + * is `{}`: Stalwart refuses null for these maps. + */ +/** The id prefix of `calendar`'s default alerts for timed or all-day events, unique in its account. */ +export function defaultAlertIdPrefix(calendar: Pick, allDay: boolean): string { + return `cal-${calendar.originalId || calendar.id}-${allDay ? 'allday' : 'timed'}`; +} + +export function remindersToDefaultAlerts( + reminders: Reminder[], + preserved: Record = {}, + idPrefix = 'default', +): Record { + const alerts: Record = { ...preserved }; + let n = 0; + for (const minutesBefore of reminderMinutes(reminders)) { + let key = `${idPrefix}-${++n}`; + while (key in alerts) key = `${idPrefix}-${++n}`; + alerts[key] = { + '@type': 'Alert', + trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(minutesBefore), relativeTo: 'start' }, + action: 'display', + }; + } + return alerts; +} + /** * What an editor save writes for the reminders: nothing while they are the * ones the event had, so an event that uses its calendar's default alerts diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 6c8bedf7..e310c11f 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -93,7 +93,7 @@ import { buildReplyTo } from '../lib/calendar-invitation'; import { isRecurringSeriesMember } from '../lib/recurrence-overrides'; import { deleteOccurrence, saveOccurrenceEdit, type SeriesEdit } from '../lib/recurrence-scope-edit'; import { participantLimitMessage } from '../lib/calendar-participants'; -import { maxParticipantsPerEvent } from '../api/calendar'; +import { maxParticipantsPerEvent, type CalendarUpdates } from '../api/calendar'; import { useContactNameResolver } from '../lib/contact-name-resolver'; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar'; import { moveCalendar } from '../lib/calendar-sort'; @@ -880,7 +880,10 @@ export default function CalendarScreen() { } else { // Only what the user changed: an untouched colour another client // wrote may be one Stalwart 1.0 refuses. - const changes = changedCalendarFields(calendarEditTarget.calendar, values); + const changes: CalendarUpdates = changedCalendarFields(calendarEditTarget.calendar, values); + // The default reminders come only when the user changed them. + if (values.defaultAlertsWithTime) changes.defaultAlertsWithTime = values.defaultAlertsWithTime; + if (values.defaultAlertsWithoutTime) changes.defaultAlertsWithoutTime = values.defaultAlertsWithoutTime; if (Object.keys(changes).length > 0) await updateCalendar(calendarEditTarget.calendar.id, changes); } },