From 8181f5ba0cdd7e97207ace887f3512dfa98b2aec Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:27:22 +0200 Subject: [PATCH] feat: start new events on the calendar's default reminders #1209 --- .../__tests__/event-modal-alerts.test.tsx | 128 ++++++++++++++++++ components/calendar/alert-list-editor.tsx | 49 +++++-- components/calendar/event-modal.tsx | 105 +++++++++++--- lib/__tests__/calendar-alerts.test.ts | 7 + lib/calendar-alerts.ts | 6 + locales/en/common.json | 6 +- 6 files changed, 271 insertions(+), 30 deletions(-) diff --git a/components/calendar/__tests__/event-modal-alerts.test.tsx b/components/calendar/__tests__/event-modal-alerts.test.tsx index b59522588..b2b98ae26 100644 --- a/components/calendar/__tests__/event-modal-alerts.test.tsx +++ b/components/calendar/__tests__/event-modal-alerts.test.tsx @@ -6,6 +6,18 @@ import type { Calendar, CalendarEvent, CalendarEventAlert } from '@/lib/jmap/typ // #1209: the reminder rows edit the action (notification or email), and // alerts the rows can't show are saved back unchanged. +// Keep the values in the label so "15 minutes" and "1 hour" differ. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => + values ? `${key}(${Object.values(values).join(',')})` : key, + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (d: Date | string) => String(d), + relativeTime: (d: Date | string) => String(d), + number: (n: number) => String(n), + }), +})); + const calendars = [ { id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } }, ] as unknown as Calendar[]; @@ -102,3 +114,119 @@ describe('EventModal reminder actions', () => { expect(data.alerts).toEqual({ after }); }); }); + +// Calendar default alerts: a new event uses them; Stalwart copies them into +// the event on create but not when the flag is set on an update. +describe('EventModal calendar default alerts', () => { + const offset = (o: string, action: CalendarEventAlert['action'] = 'display'): CalendarEventAlert => ({ + '@type': 'Alert', + trigger: { '@type': 'OffsetTrigger', offset: o, relativeTo: 'start' }, + action, + acknowledged: null, + relatedTo: null, + }); + const work = { + id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true }, + defaultAlertsWithTime: { w1: offset('-PT15M') }, + defaultAlertsWithoutTime: { w2: offset('-P1D', 'email') }, + }; + const home = { + id: 'cal-2', name: 'Home', isDefault: false, myRights: { mayWriteAll: true, mayWriteOwn: true }, + defaultAlertsWithTime: { h1: offset('-PT1H', 'email') }, + defaultAlertsWithoutTime: null, + }; + const withDefaults = [work, home] as unknown as Calendar[]; + + function renderNew() { + const onSave = vi.fn().mockResolvedValue(undefined); + render( + , + ); + fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Sync' } }); + return onSave; + } + + function renderExisting(event: CalendarEvent, startInEditMode = true) { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + return onSave; + } + + it('starts a new event on the calendar defaults and saves only the flag', async () => { + const onSave = renderNew(); + + expect(screen.getByText('alerts.calendar_defaults')).toBeTruthy(); + expect(screen.getByText('alerts.minutes_before(15)')).toBeTruthy(); + expect(screen.queryByLabelText('alerts.unit')).toBeNull(); + + const data = await savedData(onSave); + expect(data.useDefaultAlerts).toBe(true); + expect(data).not.toHaveProperty('alerts'); + }); + + it('shows the defaults for the all-day state and the selected calendar', () => { + renderNew(); + + fireEvent.click(screen.getByLabelText('form.all_day_event')); + expect(screen.getByText('alerts.by_email(alerts.days_before(1))')).toBeTruthy(); + expect(screen.queryByText('alerts.minutes_before(15)')).toBeNull(); + + fireEvent.click(screen.getByLabelText('form.all_day_event')); + fireEvent.change(screen.getByDisplayValue('Work'), { target: { value: 'cal-2' } }); + expect(screen.getByText('alerts.by_email(alerts.hours_before(1))')).toBeTruthy(); + }); + + it('copies the defaults into editable rows on Customize and saves them as custom alerts', async () => { + const onSave = renderNew(); + fireEvent.change(screen.getByDisplayValue('Work'), { target: { value: 'cal-2' } }); + + fireEvent.click(screen.getByText('alerts.customize')); + expect((screen.getByLabelText('alerts.unit') as HTMLSelectElement).value).toBe('hours'); + expect((screen.getByLabelText('alerts.action') as HTMLSelectElement).value).toBe('email'); + fireEvent.change(screen.getByLabelText('alerts.amount'), { target: { value: '2' } }); + + const data = await savedData(onSave); + expect(data.useDefaultAlerts).toBe(false); + expect(Object.values(data.alerts!)).toEqual([ + expect.objectContaining({ action: 'email', trigger: expect.objectContaining({ offset: '-PT2H' }) }), + ]); + }); + + it('opens a defaults event in defaults mode and keeps the copy the server stored', async () => { + const stored = { k1: { ...offset('-PT30M'), trigger: { '@type': 'OffsetTrigger', offset: '-PT30M' } } } as unknown as Record; + const onSave = renderExisting(makeEvent({ useDefaultAlerts: true, alerts: stored })); + + expect(screen.getByText('alerts.calendar_defaults')).toBeTruthy(); + expect(screen.getByText('alerts.minutes_before(30)')).toBeTruthy(); + + const data = await savedData(onSave); + expect(data.useDefaultAlerts).toBe(true); + expect(data.alerts).toEqual(stored); + }); + + it('sends the calendar defaults when a custom event goes back to them', async () => { + const onSave = renderExisting(makeEvent()); + + fireEvent.click(screen.getByText('alerts.use_defaults')); + expect(screen.getByText('alerts.minutes_before(15)')).toBeTruthy(); + + const data = await savedData(onSave); + expect(data.useDefaultAlerts).toBe(true); + expect(data.alerts).toEqual({ w1: offset('-PT15M') }); + }); + + it('sends the new calendar defaults when a defaults event moves to another calendar', async () => { + const onSave = renderExisting(makeEvent({ useDefaultAlerts: true, alerts: { k1: offset('-PT15M') } })); + + fireEvent.change(screen.getByDisplayValue('Work'), { target: { value: 'cal-2' } }); + const data = await savedData(onSave); + + expect(data.useDefaultAlerts).toBe(true); + expect(data.alerts).toEqual({ h1: offset('-PT1H', 'email') }); + }); + + it('labels the effective alerts of a defaults event in view mode', () => { + renderExisting(makeEvent({ useDefaultAlerts: true, alerts: null }), false); + expect(screen.getByText('alerts.minutes_before(15)')).toBeTruthy(); + }); +}); diff --git a/components/calendar/alert-list-editor.tsx b/components/calendar/alert-list-editor.tsx index 7245d06bc..7333f4577 100644 --- a/components/calendar/alert-list-editor.tsx +++ b/components/calendar/alert-list-editor.tsx @@ -1,9 +1,12 @@ "use client"; +import type { ReactNode } from "react"; import { useTranslations } from "next-intl"; import { Input } from "@/components/ui/input"; import { Trash2, Plus } from "@/components/icons"; +import type { CalendarEventAlert } from "@/lib/jmap/types"; import { + describeAlert, newAlertRow, type AlertAction, type AlertRow, @@ -28,12 +31,35 @@ export function formatAlertRowLabel(row: Omit, t: CalendarT): st return row.action === "email" ? t("alerts.by_email", { label }) : label; } +/** Read-only list of an alert map, for alerts that come from calendar defaults. */ +export function AlertSummary({ alerts }: { alerts: Record | null }) { + const t = useTranslations("calendar"); + const labels: string[] = []; + let other = 0; + for (const alert of Object.values(alerts ?? {})) { + const row = describeAlert(alert); + if (row) labels.push(formatAlertRowLabel(row, t)); + else other += 1; + } + if (labels.length === 0 && other === 0) { + return

{t("alerts.none")}

; + } + return ( +
    + {labels.map((label, i) =>
  • {label}
  • )} + {other > 0 &&
  • {t("alerts.other", { count: other })}
  • } +
+ ); +} + interface AlertListEditorProps { rows: AlertRow[]; onChange: (rows: AlertRow[]) => void; /** Alerts the rows can't show (absolute, after the start); saved unchanged. */ keptCount?: number; disabled?: boolean; + /** Shown next to "Add reminder". */ + extraAction?: ReactNode; } /** @@ -41,7 +67,7 @@ interface AlertListEditorProps { * Each row is an offset before the start; alerts that don't fit stay with the * caller and are only counted here. */ -export function AlertListEditor({ rows, onChange, keptCount = 0, disabled = false }: AlertListEditorProps) { +export function AlertListEditor({ rows, onChange, keptCount = 0, disabled = false, extraAction }: AlertListEditorProps) { const t = useTranslations("calendar"); const update = (id: string, patch: Partial>) => { @@ -113,15 +139,18 @@ export function AlertListEditor({ rows, onChange, keptCount = 0, disabled = fals )} )} - +
+ + {extraAction} +
); } diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 8cf37c3c5..800596c73 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -9,8 +9,15 @@ import { LinkifiedText } from "@/components/ui/linkified-text"; import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft } from "@/components/icons"; import { format, parseISO, addHours, isSameDay } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; -import { AlertListEditor, formatAlertRowLabel } from "./alert-list-editor"; -import { alertsToRows, describeAlert, rowsToAlerts, type AlertRow } from "@/lib/calendar-alerts"; +import { AlertListEditor, AlertSummary, formatAlertRowLabel } from "./alert-list-editor"; +import { + alertsToRows, + describeAlert, + getCalendarDefaultAlerts, + getEffectiveAlerts, + rowsToAlerts, + type AlertRow, +} from "@/lib/calendar-alerts"; import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; @@ -119,10 +126,11 @@ function formatDurationDisplay(minutes: number): string { return `${h}h${m}min`; } -function getAlertLabel(event: CalendarEvent, t: ReturnType): string | null { - if (!event.alerts) return null; +function getAlertLabel(event: CalendarEvent, calendars: Calendar[], t: ReturnType): string | null { + const alerts = getEffectiveAlerts(event, calendars); + if (!alerts) return null; const labels: string[] = []; - for (const alert of Object.values(event.alerts)) { + for (const alert of Object.values(alerts)) { const row = describeAlert(alert); if (!row) continue; labels.push(formatAlertRowLabel(row, t)); @@ -326,11 +334,38 @@ export function EventModal({ () => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null), [customRule, t, locale] ); + // A new event starts on the calendar's default alerts. + const [useDefaultAlerts, setUseDefaultAlerts] = useState(() => (event ? event.useDefaultAlerts === true : true)); // Alerts the rows can't represent (absolute triggers, offsets after the // start or relative to the end) are kept here so they survive a save. - const [initialAlerts] = useState(() => alertsToRows(event?.alerts)); - const preservedAlertsRef = useRef>(initialAlerts.preserved); + const [initialAlerts] = useState(() => + event && !event.useDefaultAlerts ? alertsToRows(event.alerts) : { rows: [], preserved: {} } + ); + const [preservedAlerts, setPreservedAlerts] = useState>(initialAlerts.preserved); const [alertRows, setAlertRows] = useState(initialAlerts.rows); + + // What defaults mode stands for. Stalwart copies the calendar's defaults + // into an event only when it is created, so an existing event that stays + // in its calendar and keeps its all-day state shows the copy it stored; + // otherwise the selected calendar's current defaults apply. + const defaultAlerts = useMemo(() => { + if ( + event?.useDefaultAlerts && + event.alerts && Object.keys(event.alerts).length > 0 && + getPrimaryCalendarId(event) === calendarId && + !!event.showWithoutTime === allDay + ) { + return event.alerts; + } + return getCalendarDefaultAlerts(calendars.find((c) => c.id === calendarId), allDay); + }, [event, calendars, calendarId, allDay]); + + const customizeAlerts = useCallback(() => { + const { rows, preserved } = alertsToRows(defaultAlerts); + setAlertRows(rows); + setPreservedAlerts(preserved); + setUseDefaultAlerts(false); + }, [defaultAlerts]); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isSaving, setIsSaving] = useState(false); // Set synchronously, so a second save in the same tick is refused too. @@ -529,11 +564,20 @@ export function EventModal({ if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null; } - const builtAlerts = rowsToAlerts(alertRows, preservedAlertsRef.current); - if (Object.keys(builtAlerts).length > 0) { - data.alerts = builtAlerts; - } else if (event && event.alerts && Object.keys(event.alerts).length > 0) { - data.alerts = null; + if (useDefaultAlerts) { + data.useDefaultAlerts = true; + // Stalwart copies the calendar's defaults into a new event itself + // (sending alerts too would double them), but setting the flag on an + // update keeps whatever alerts the event had, so updates carry them. + if (event) data.alerts = defaultAlerts ? structuredClone(defaultAlerts) : null; + } else { + data.useDefaultAlerts = false; + const builtAlerts = rowsToAlerts(alertRows, preservedAlerts); + if (Object.keys(builtAlerts).length > 0) { + data.alerts = builtAlerts; + } else if (event && event.alerts && Object.keys(event.alerts).length > 0) { + data.alerts = null; + } } if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) { @@ -610,7 +654,7 @@ export function EventModal({ savingRef.current = false; setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving, t]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving, t]); const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => { if (!event || !userParticipantId || !onRsvp) return; @@ -785,7 +829,7 @@ export function EventModal({ const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null; const viewParticipants = getParticipantList(event, { resolveName: resolveContactName }); const recurrenceLabel = getRecurrenceLabel(event, t, locale); - const alertLabel = getAlertLabel(event, t); + const alertLabel = getAlertLabel(event, calendars, t); const eventCalendar = calendars.find(c => event.calendarIds[c.id]); const color = getEventColor(event, eventCalendar); @@ -1246,11 +1290,34 @@ export function EventModal({
- + {useDefaultAlerts ? ( +
+

{t("alerts.calendar_defaults")}

+ + +
+ ) : ( + setUseDefaultAlerts(true)} + className="inline-flex items-center gap-1 text-sm text-primary hover:underline" + > + {t("alerts.use_defaults")} + + } + /> + )}
{attendees.length > 0 && ( diff --git a/lib/__tests__/calendar-alerts.test.ts b/lib/__tests__/calendar-alerts.test.ts index bdc13e64d..484ece3a7 100644 --- a/lib/__tests__/calendar-alerts.test.ts +++ b/lib/__tests__/calendar-alerts.test.ts @@ -230,6 +230,13 @@ describe('getEffectiveAlerts', () => { expect(getEffectiveAlerts(event, calendars)).toBe(defaultAlerts); }); + it('prefers the alerts the server copied into a defaults event over the current calendar defaults', () => { + const copied = { k1: makeAlert({ action: 'email' }) }; + const event = makeEvent({ useDefaultAlerts: true, alerts: copied }); + const calendars = [makeCalendar({ defaultAlertsWithTime: { d1: makeAlert() } })]; + expect(getEffectiveAlerts(event, calendars)).toBe(copied); + }); + it('returns null when calendar not found', () => { const event = makeEvent({ useDefaultAlerts: true, calendarIds: { 'missing': true } }); const calendars = [makeCalendar()]; diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts index 2d29adc52..ebcd2d36b 100644 --- a/lib/calendar-alerts.ts +++ b/lib/calendar-alerts.ts @@ -76,6 +76,12 @@ export function getEffectiveAlerts( if (!event.useDefaultAlerts) { return event.alerts; } + // Stalwart copies the calendar's defaults into the event when it is + // created and later default changes don't reach it. Its copy is what the + // server (email alerts) and other clients fire, so follow it when present. + if (event.alerts && Object.keys(event.alerts).length > 0) { + return event.alerts; + } if (!event.calendarIds) return null; const calendarId = Object.keys(event.calendarIds)[0]; diff --git a/locales/en/common.json b/locales/en/common.json index 3deee60d9..b91daf038 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3248,7 +3248,11 @@ "action_display": "Notification", "action_email": "Email", "by_email": "{label} by email", - "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}}" + "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}}", + "other": "{count, plural, one {# other reminder} other {# other reminders}}", + "calendar_defaults": "Calendar defaults", + "customize": "Customize", + "use_defaults": "Use calendar defaults" }, "settings": { "limit_hours": "Limit visible hours",