feat: start new events on the calendar's default reminders #1209

This commit is contained in:
Linus Rath
2026-10-13 15:56:10 +02:00
parent 4e983e3e4e
commit 8181f5ba0c
6 changed files with 271 additions and 30 deletions
@@ -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<string, unknown>) =>
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(
<EventModal calendars={withDefaults} defaultDate={new Date('2026-11-20T10:00:00')} defaultCalendarId="cal-1" onSave={onSave} onClose={vi.fn()} />,
);
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Sync' } });
return onSave;
}
function renderExisting(event: CalendarEvent, startInEditMode = true) {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<EventModal event={event} startInEditMode={startInEditMode} calendars={withDefaults} onSave={onSave} onClose={vi.fn()} />);
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<string, CalendarEventAlert>;
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();
});
});
+39 -10
View File
@@ -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<AlertRow, "id">, 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<string, CalendarEventAlert> | 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 <p className="text-sm text-muted-foreground">{t("alerts.none")}</p>;
}
return (
<ul className="text-sm space-y-0.5">
{labels.map((label, i) => <li key={i}>{label}</li>)}
{other > 0 && <li className="text-muted-foreground">{t("alerts.other", { count: other })}</li>}
</ul>
);
}
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<Omit<AlertRow, "id">>) => {
@@ -113,15 +139,18 @@ export function AlertListEditor({ rows, onChange, keptCount = 0, disabled = fals
)}
</div>
)}
<button
type="button"
disabled={disabled}
onClick={() => onChange([...rows, newAlertRow(10, "minutes")])}
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline disabled:opacity-50"
>
<Plus className="w-4 h-4" />
{t("alerts.add")}
</button>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
<button
type="button"
disabled={disabled}
onClick={() => onChange([...rows, newAlertRow(10, "minutes")])}
className="inline-flex items-center gap-1 text-sm text-primary hover:underline disabled:opacity-50"
>
<Plus className="w-4 h-4" />
{t("alerts.add")}
</button>
{extraAction}
</div>
</div>
);
}
+86 -19
View File
@@ -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<typeof useTranslations>): string | null {
if (!event.alerts) return null;
function getAlertLabel(event: CalendarEvent, calendars: Calendar[], t: ReturnType<typeof useTranslations>): 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<Record<string, CalendarEventAlert>>(initialAlerts.preserved);
const [initialAlerts] = useState(() =>
event && !event.useDefaultAlerts ? alertsToRows(event.alerts) : { rows: [], preserved: {} }
);
const [preservedAlerts, setPreservedAlerts] = useState<Record<string, CalendarEventAlert>>(initialAlerts.preserved);
const [alertRows, setAlertRows] = useState<AlertRow[]>(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({
<div>
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
<AlertListEditor
rows={alertRows}
onChange={setAlertRows}
keptCount={Object.keys(preservedAlertsRef.current).length}
/>
{useDefaultAlerts ? (
<div>
<p className="text-xs text-muted-foreground mb-1">{t("alerts.calendar_defaults")}</p>
<AlertSummary alerts={defaultAlerts} />
<button
type="button"
onClick={customizeAlerts}
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
{t("alerts.customize")}
</button>
</div>
) : (
<AlertListEditor
rows={alertRows}
onChange={setAlertRows}
keptCount={Object.keys(preservedAlerts).length}
extraAction={
<button
type="button"
onClick={() => setUseDefaultAlerts(true)}
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
{t("alerts.use_defaults")}
</button>
}
/>
)}
</div>
{attendees.length > 0 && (
+7
View File
@@ -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()];
+6
View File
@@ -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];
+5 -1
View File
@@ -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",