This commit is contained in:
Linus Rath
2026-10-13 15:58:52 +02:00
11 changed files with 1060 additions and 199 deletions
@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { EventDetailPopover } from '../event-detail-popover';
import type { Calendar, CalendarEvent, CalendarEventAlert } from '@/lib/jmap/types';
// #1209: the popover listed only the first alert, as "before" even for email
// alerts, and nothing for an event on its calendar's defaults.
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 alert = (offset: string, action: CalendarEventAlert['action']): CalendarEventAlert => ({
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset, relativeTo: 'start' },
action,
acknowledged: null,
relatedTo: null,
});
const calendar = {
id: 'cal-1',
name: 'Work',
myRights: { mayWriteAll: true, mayWriteOwn: true },
defaultAlertsWithTime: { d1: alert('-PT30M', 'display') },
defaultAlertsWithoutTime: null,
} as unknown as Calendar;
function renderPopover(overrides: Partial<CalendarEvent>) {
const event = {
id: 'evt-1',
calendarIds: { 'cal-1': true },
title: 'Review',
start: '2026-11-20T10:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
showWithoutTime: false,
participants: null,
alerts: null,
useDefaultAlerts: false,
...overrides,
} as unknown as CalendarEvent;
render(
<EventDetailPopover
event={event}
calendar={calendar}
anchorRect={new DOMRect(100, 100, 50, 20)}
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onClose={vi.fn()}
onSaveNote={vi.fn()}
/>,
);
}
describe('EventDetailPopover reminders', () => {
it('lists every alert and marks email alerts', () => {
renderPopover({ alerts: { a: alert('-PT15M', 'display'), b: alert('-PT1H', 'email') } });
expect(screen.getByText('alerts.minutes_before(15), alerts.by_email(alerts.hours_before(1))')).toBeTruthy();
});
it("shows the calendar's defaults for an event that uses them", () => {
renderPopover({ useDefaultAlerts: true });
expect(screen.getByText('alerts.minutes_before(30)')).toBeTruthy();
});
});
@@ -0,0 +1,244 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { EventModal } from '../event-modal';
import type { Calendar, CalendarEvent, CalendarEventAlert } from '@/lib/jmap/types';
// #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[];
const absolute: CalendarEventAlert = {
'@type': 'Alert',
trigger: { '@type': 'AbsoluteTrigger', when: '2026-11-19T08:00:00Z' },
action: 'email',
acknowledged: null,
relatedTo: null,
};
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'evt-1',
'@type': 'Event',
uid: 'uid-1',
calendarIds: { 'cal-1': true },
title: 'Review',
description: '',
start: '2026-11-20T10:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
showWithoutTime: false,
participants: null,
recurrenceRules: null,
locations: null,
virtualLocations: null,
useDefaultAlerts: false,
alerts: {
a15: {
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' },
action: 'display',
acknowledged: null,
relatedTo: null,
},
abs: absolute,
},
...overrides,
} as unknown as CalendarEvent;
}
function renderEditor(event: CalendarEvent) {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<EventModal event={event} startInEditMode calendars={calendars} onSave={onSave} onClose={vi.fn()} />);
return onSave;
}
async function savedData(onSave: ReturnType<typeof vi.fn>): Promise<Partial<CalendarEvent>> {
fireEvent.click(screen.getByText('form.save'));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
return onSave.mock.calls[0][0];
}
describe('EventModal reminder actions', () => {
it('saves a row switched to email with action email', async () => {
const onSave = renderEditor(makeEvent());
fireEvent.change(screen.getByLabelText('alerts.action'), { target: { value: 'email' } });
const data = await savedData(onSave);
const offsets = Object.values(data.alerts!).filter((a) => a.trigger['@type'] === 'OffsetTrigger');
expect(offsets).toHaveLength(1);
expect(offsets[0]).toMatchObject({ action: 'email', trigger: { offset: '-PT15M', relativeTo: 'start' } });
});
it('opens an email alert as an email row', () => {
renderEditor(makeEvent({
alerts: {
e: { ...absolute, trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' } },
},
}));
expect((screen.getByLabelText('alerts.action') as HTMLSelectElement).value).toBe('email');
expect((screen.getByLabelText('alerts.unit') as HTMLSelectElement).value).toBe('hours');
});
it('keeps an absolute alert through a save', async () => {
const onSave = renderEditor(makeEvent());
fireEvent.click(screen.getByLabelText('alerts.remove'));
const data = await savedData(onSave);
expect(data.alerts).toEqual({ abs: absolute });
});
it('keeps an alert after the start instead of turning it into one before', async () => {
const after: CalendarEventAlert = { ...absolute, action: 'display', trigger: { '@type': 'OffsetTrigger', offset: 'PT10M', relativeTo: 'start' } };
const onSave = renderEditor(makeEvent({ alerts: { after } }));
expect(screen.queryByLabelText('alerts.unit')).toBeNull();
const data = await savedData(onSave);
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('takes the current calendar defaults after Customize and Use calendar defaults', async () => {
const onSave = renderExisting(makeEvent({ useDefaultAlerts: true, alerts: { k1: offset('-PT30M') } }));
fireEvent.click(screen.getByText('alerts.customize'));
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();
});
});
+169
View File
@@ -0,0 +1,169 @@
"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 { Calendar, CalendarEvent, CalendarEventAlert } from "@/lib/jmap/types";
import {
describeAlert,
getEffectiveAlerts,
newAlertRow,
type AlertAction,
type AlertRow,
type AlertUnit,
} from "@/lib/calendar-alerts";
type CalendarT = ReturnType<typeof useTranslations>;
const selectClass =
"rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
/** "15 minutes before", plus "by email" for email alerts. */
export function formatAlertRowLabel(row: Omit<AlertRow, "id">, t: CalendarT): string {
let label: string;
switch (row.unit) {
case "at_time": label = t("alerts.at_time"); break;
case "minutes": label = t("alerts.minutes_before", { count: row.value }); break;
case "hours": label = t("alerts.hours_before", { count: row.value }); break;
case "days": label = t("alerts.days_before", { count: row.value }); break;
case "weeks": label = t("alerts.weeks_before", { count: row.value }); break;
}
return row.action === "email" ? t("alerts.by_email", { label }) : label;
}
/** One line for an event's effective alerts, calendar defaults included. */
export function formatEventAlertsLabel(event: CalendarEvent, calendars: Calendar[], t: CalendarT): string | null {
const alerts = getEffectiveAlerts(event, calendars);
if (!alerts) return null;
const labels: string[] = [];
for (const alert of Object.values(alerts)) {
const row = describeAlert(alert);
if (row) labels.push(formatAlertRowLabel(row, t));
}
return labels.length > 0 ? labels.join(", ") : null;
}
/** 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;
}
/**
* Edits reminders as rows of amount, unit and action (notification or email).
* 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, extraAction }: AlertListEditorProps) {
const t = useTranslations("calendar");
const update = (id: string, patch: Partial<Omit<AlertRow, "id">>) => {
onChange(rows.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
return (
<div>
{rows.length === 0 && keptCount === 0 ? (
<p className="text-sm text-muted-foreground">{t("alerts.none")}</p>
) : (
<div className="space-y-2">
{rows.map((row) => (
<div key={row.id} className="flex items-center gap-2">
{row.unit !== "at_time" && (
<Input
type="number"
min={1}
max={999}
value={row.value}
disabled={disabled}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
update(row.id, { value: Number.isFinite(n) ? Math.max(1, n) : 1 });
}}
className="w-20"
aria-label={t("alerts.amount")}
/>
)}
<select
value={row.unit}
disabled={disabled}
onChange={(e) => {
const unit = e.target.value as AlertUnit;
update(row.id, { unit, value: unit === "at_time" ? 0 : (row.value || 1) });
}}
className={`flex-1 min-w-0 ${selectClass}`}
aria-label={t("alerts.unit")}
>
<option value="at_time">{t("alerts.at_time")}</option>
<option value="minutes">{t("alerts.unit_minutes_before")}</option>
<option value="hours">{t("alerts.unit_hours_before")}</option>
<option value="days">{t("alerts.unit_days_before")}</option>
<option value="weeks">{t("alerts.unit_weeks_before")}</option>
</select>
<select
value={row.action}
disabled={disabled}
onChange={(e) => update(row.id, { action: e.target.value as AlertAction })}
className={selectClass}
aria-label={t("alerts.action")}
>
<option value="display">{t("alerts.action_display")}</option>
<option value="email">{t("alerts.action_email")}</option>
</select>
<button
type="button"
disabled={disabled}
onClick={() => onChange(rows.filter((r) => r.id !== row.id))}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
aria-label={t("alerts.remove")}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{keptCount > 0 && (
<p className="text-xs text-muted-foreground">{t("alerts.kept", { count: keptCount })}</p>
)}
</div>
)}
<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>
);
}
+2 -16
View File
@@ -15,6 +15,7 @@ import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/ty
import { parseDuration, getEventColor } from "./event-card";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { buildRecurrenceSummary } from "./recurrence-editor";
import { formatEventAlertsLabel } from "./alert-list-editor";
import {
getUserParticipantId,
getUserStatus,
@@ -94,21 +95,6 @@ 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;
const first = Object.values(event.alerts)[0];
if (!first || !first.trigger || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/);
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
const hourMatch = offset.match(/-?PT?(\d+)H$/);
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
return null;
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
@@ -190,7 +176,7 @@ export function EventDetailPopover({
[event, resolveContactName]
);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]);
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
const alertLabel = useMemo(() => formatEventAlertsLabel(event, calendar ? [calendar] : [], t), [event, calendar, t]);
// Gate affordances on calendar rights, not identity (see calendar-editability).
const editabilityCtx = useMemo<EditabilityContext>(() => {
+81 -179
View File
@@ -6,9 +6,11 @@ import { toast } from "@/stores/toast-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LinkifiedText } from "@/components/ui/linkified-text";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "@/components/icons";
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, AlertSummary, formatEventAlertsLabel } from "./alert-list-editor";
import { alertsToRows, getCalendarDefaultAlerts, 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";
@@ -109,65 +111,6 @@ function buildDuration(startDate: Date, endDate: Date): string {
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom";
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
interface AlertRow {
id: string;
value: number;
unit: AlertUnit;
}
let alertRowSeq = 0;
function newAlertRow(value: number, unit: AlertUnit): AlertRow {
alertRowSeq += 1;
return { id: `r${alertRowSeq}`, value, unit };
}
function alertRowToOffset(row: AlertRow): string | null {
if (row.unit === "at_time") return "PT0S";
const v = Math.max(0, Math.floor(row.value));
if (!Number.isFinite(v) || v <= 0) return null;
switch (row.unit) {
case "minutes": return `-PT${v}M`;
case "hours": return `-PT${v}H`;
case "days": return `-P${v}D`;
case "weeks": return `-P${v}W`;
}
}
function offsetToAlertRow(offset: string): AlertRow | null {
if (offset === "PT0S" || offset === "P0D" || offset === "PT0M") {
return newAlertRow(0, "at_time");
}
let m = offset.match(/^-?P(\d+)W$/);
if (m) return newAlertRow(parseInt(m[1], 10), "weeks");
m = offset.match(/^-?P(\d+)D$/);
if (m) return newAlertRow(parseInt(m[1], 10), "days");
m = offset.match(/^-?PT(\d+)H$/);
if (m) return newAlertRow(parseInt(m[1], 10), "hours");
m = offset.match(/^-?PT(\d+)M$/);
if (m) {
const mins = parseInt(m[1], 10);
if (mins > 0 && mins % 1440 === 0) return newAlertRow(mins / 1440, "days");
if (mins > 0 && mins % 60 === 0) return newAlertRow(mins / 60, "hours");
return newAlertRow(mins, "minutes");
}
return null;
}
function formatAlertRowLabel(
row: { value: number; unit: AlertUnit },
t: ReturnType<typeof useTranslations>
): string {
if (row.unit === "at_time") return t("alerts.at_time");
switch (row.unit) {
case "minutes": return t("alerts.minutes_before", { count: row.value });
case "hours": return t("alerts.hours_before", { count: row.value });
case "days": return t("alerts.days_before", { count: row.value });
case "weeks": return t("alerts.weeks_before", { count: row.value });
}
}
function formatDurationDisplay(minutes: number): string {
if (minutes < 60) return `${minutes}min`;
const h = Math.floor(minutes / 60);
@@ -176,19 +119,6 @@ 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;
const labels: string[] = [];
for (const alert of Object.values(event.alerts)) {
if (alert.trigger["@type"] !== "OffsetTrigger") continue;
const row = offsetToAlertRow(alert.trigger.offset);
if (!row) continue;
labels.push(formatAlertRowLabel(row, t));
}
if (labels.length === 0) return null;
return labels.join(", ");
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
@@ -384,36 +314,43 @@ export function EventModal({
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
[customRule, t, locale]
);
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);
}
return rows;
});
// 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(() =>
event && !event.useDefaultAlerts ? alertsToRows(event.alerts) : { rows: [], preserved: {} }
);
const [preservedAlerts, setPreservedAlerts] = useState<Record<string, CalendarEventAlert>>(initialAlerts.preserved);
const [alertRows, setAlertRows] = useState<AlertRow[]>(initialAlerts.rows);
const addAlertRow = useCallback(() => {
setAlertRows((prev) => [...prev, newAlertRow(10, "minutes")]);
}, []);
const updateAlertRow = useCallback((id: string, patch: Partial<Omit<AlertRow, "id">>) => {
setAlertRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}, []);
const removeAlertRow = useCallback((id: string) => {
setAlertRows((prev) => prev.filter((r) => r.id !== id));
}, []);
// Set once "Use calendar defaults" is clicked: from then on the event takes
// the calendar's current defaults, not the copy it stored.
const [refreshDefaults, setRefreshDefaults] = useState(false);
// 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 (
!refreshDefaults &&
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);
}, [refreshDefaults, 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.
@@ -612,25 +549,20 @@ 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;
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) {
@@ -707,7 +639,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;
@@ -882,7 +814,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 = formatEventAlertsLabel(event, calendars, t);
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
const color = getEventColor(event, eventCalendar);
@@ -1343,64 +1275,34 @@ export function EventModal({
<div>
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
{alertRows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("alerts.none")}</p>
) : (
<div className="space-y-2">
{alertRows.map((row) => (
<div key={row.id} className="flex items-center gap-2">
{row.unit !== "at_time" && (
<Input
type="number"
min={1}
max={999}
value={row.value}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
updateAlertRow(row.id, { value: Number.isFinite(n) ? Math.max(1, n) : 1 });
}}
className="w-20"
aria-label={t("alerts.amount")}
/>
)}
<select
value={row.unit}
onChange={(e) => {
const unit = e.target.value as AlertUnit;
updateAlertRow(row.id, {
unit,
value: unit === "at_time" ? 0 : (row.value || 1),
});
}}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={t("alerts.unit")}
>
<option value="at_time">{t("alerts.at_time")}</option>
<option value="minutes">{t("alerts.unit_minutes_before")}</option>
<option value="hours">{t("alerts.unit_hours_before")}</option>
<option value="days">{t("alerts.unit_days_before")}</option>
<option value="weeks">{t("alerts.unit_weeks_before")}</option>
</select>
<button
type="button"
onClick={() => removeAlertRow(row.id)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("alerts.remove")}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{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); setRefreshDefaults(true); }}
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
{t("alerts.use_defaults")}
</button>
}
/>
)}
<button
type="button"
onClick={addAlertRow}
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<Plus className="w-4 h-4" />
{t("alerts.add")}
</button>
</div>
{attendees.length > 0 && (
@@ -0,0 +1,98 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CalendarManagementSettings } from '../calendar-management-settings';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Calendar, CalendarEventAlert } from '@/lib/jmap/types';
// #1209: each calendar the user owns gets editable default reminders for
// timed and all-day events.
const absolute: CalendarEventAlert = {
'@type': 'Alert',
trigger: { '@type': 'AbsoluteTrigger', when: '2026-11-19T08:00:00Z' },
action: 'display',
acknowledged: null,
relatedTo: null,
};
function makeCalendar(overrides: Partial<Calendar>): Calendar {
return {
id: 'cal-1',
name: 'Work',
color: '#3b82f6',
isDefault: true,
isShared: false,
shareWith: null,
myRights: { mayWriteAll: true, mayWriteOwn: true, mayShare: true, mayDelete: true },
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
...overrides,
} as unknown as Calendar;
}
const client = { getAccountId: () => 'acct' };
let updateCalendar: ReturnType<typeof vi.fn>;
beforeEach(() => {
updateCalendar = vi.fn().mockResolvedValue(undefined);
useAuthStore.setState({ client: client as never, serverUrl: null as never, username: null as never });
useCalendarStore.setState({
calendars: [
makeCalendar({ defaultAlertsWithoutTime: { abs: absolute } }),
makeCalendar({ id: 'cal-2', name: 'Team', isDefault: false, isShared: true }),
],
icalSubscriptions: [],
updateCalendar,
} as never);
});
describe('Calendar default reminders settings', () => {
it('offers default reminders only for calendars the user owns', () => {
render(<CalendarManagementSettings />);
expect(screen.getAllByLabelText('default_alerts')).toHaveLength(1);
});
it('saves both lists with their actions and keeps alerts it cannot show', async () => {
render(<CalendarManagementSettings />);
fireEvent.click(screen.getByLabelText('default_alerts'));
// Timed list first, then all-day.
const [addTimed, addAllDay] = screen.getAllByText('alerts.add');
fireEvent.click(addTimed);
fireEvent.change(screen.getByLabelText('alerts.action'), { target: { value: 'email' } });
fireEvent.click(addAllDay);
fireEvent.change(screen.getAllByLabelText('alerts.unit')[1], { target: { value: 'days' } });
fireEvent.change(screen.getAllByLabelText('alerts.amount')[1], { target: { value: '1' } });
fireEvent.click(screen.getByText('save'));
await waitFor(() => expect(updateCalendar).toHaveBeenCalledTimes(1));
const [, calendarId, updates] = updateCalendar.mock.calls[0];
expect(calendarId).toBe('cal-1');
expect(Object.values(updates.defaultAlertsWithTime)).toEqual([
expect.objectContaining({ action: 'email', trigger: { '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'start' } }),
]);
expect(updates.defaultAlertsWithoutTime.abs).toBe(absolute);
expect(Object.values(updates.defaultAlertsWithoutTime)).toContainEqual(
expect.objectContaining({ action: 'display', trigger: expect.objectContaining({ offset: '-P1D' }) }),
);
});
it('clears a list by sending an empty map, which Stalwart accepts where it refuses null', async () => {
useCalendarStore.setState({
calendars: [makeCalendar({
defaultAlertsWithTime: {
a: { ...absolute, trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' } },
},
})],
} as never);
render(<CalendarManagementSettings />);
fireEvent.click(screen.getByLabelText('default_alerts'));
fireEvent.click(screen.getByLabelText('alerts.remove'));
fireEvent.click(screen.getByText('save'));
await waitFor(() => expect(updateCalendar).toHaveBeenCalledTimes(1));
expect(updateCalendar.mock.calls[0][2]).toEqual({ defaultAlertsWithTime: {}, defaultAlertsWithoutTime: {} });
});
});
@@ -4,12 +4,14 @@ import { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useCalendarStore } from '@/stores/calendar-store';
import { CALENDAR_KIND_COMPONENTS, CalendarKindPicker, type CalendarKind } from '@/components/calendar/calendar-kind-picker';
import type { CalendarComponentType } from '@/lib/jmap/types';
import type { Calendar, CalendarComponentType } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from '@/components/icons';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell } from '@/components/icons';
import { AlertListEditor } from '@/components/calendar/alert-list-editor';
import { alertsToRows, rowsToAlerts, type AlertRow } from '@/lib/calendar-alerts';
import { ShareCollectionDialog } from './share-collection-dialog';
import type { CalendarRights } from '@/lib/jmap/types';
import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils';
@@ -150,6 +152,83 @@ export function CalendarEditForm({
export { CalendarColorPicker, CALENDAR_COLORS };
type DefaultAlertsUpdate = Pick<Calendar, 'defaultAlertsWithTime' | 'defaultAlertsWithoutTime'>;
export function CalendarDefaultAlertsForm({
calendar,
onSave,
onCancel,
isLoading,
}: {
calendar: Calendar;
onSave: (updates: DefaultAlertsUpdate) => void;
onCancel: () => void;
isLoading: boolean;
}) {
const t = useTranslations('calendar.management');
// Alerts the rows can't show are written back unchanged.
const [timed] = useState(() => alertsToRows(calendar.defaultAlertsWithTime));
const [allDay] = useState(() => alertsToRows(calendar.defaultAlertsWithoutTime));
const [timedRows, setTimedRows] = useState<AlertRow[]>(timed.rows);
const [allDayRows, setAllDayRows] = useState<AlertRow[]>(allDay.rows);
// An emptied list is sent as {}: Stalwart refuses null for these maps.
const submit = () => onSave({
defaultAlertsWithTime: rowsToAlerts(timedRows, timed.preserved),
defaultAlertsWithoutTime: rowsToAlerts(allDayRows, allDay.preserved),
});
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
<div>
<h4 className="text-sm font-medium">{t('default_alerts_title', { name: calendar.name })}</h4>
<p className="text-xs text-muted-foreground mt-0.5">{t('default_alerts_hint')}</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t('default_alerts_timed')}
</label>
<AlertListEditor
rows={timedRows}
onChange={setTimedRows}
keptCount={Object.keys(timed.preserved).length}
disabled={isLoading}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t('default_alerts_all_day')}
</label>
<AlertListEditor
rows={allDayRows}
onChange={setAllDayRows}
keptCount={Object.keys(allDay.preserved).length}
disabled={isLoading}
/>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={submit}
disabled={isLoading}
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{t('save')}
</button>
<button
onClick={onCancel}
disabled={isLoading}
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
</div>
);
}
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
@@ -168,6 +247,7 @@ export function CalendarManagementSettings() {
const [isCreating, setIsCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [alertsEditingId, setAlertsEditingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [clearingId, setClearingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -329,6 +409,20 @@ export function CalendarManagementSettings() {
}
};
const handleDefaultAlertsSave = async (calendarId: string, updates: DefaultAlertsUpdate) => {
if (!client) return;
setIsLoading(true);
try {
await updateCalendar(client, calendarId, updates);
setAlertsEditingId(null);
toast.success(t('default_alerts_updated'));
} catch {
toast.error(t('error_update'));
} finally {
setIsLoading(false);
}
};
const handleColorChange = async (calendarId: string, color: string) => {
if (!client) return;
// Shared calendars: recolor locally only - the viewer usually can't write
@@ -424,6 +518,18 @@ export function CalendarManagementSettings() {
);
}
if (alertsEditingId === cal.id) {
return (
<CalendarDefaultAlertsForm
key={cal.id}
calendar={cal}
onSave={(updates) => handleDefaultAlertsSave(cal.id, updates)}
onCancel={() => setAlertsEditingId(null)}
isLoading={isLoading}
/>
);
}
if (deletingId === cal.id) {
return (
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
@@ -560,6 +666,18 @@ export function CalendarManagementSettings() {
>
<Pencil className="w-3.5 h-3.5" />
</button>
{/* The owner's defaults; a sharee can't write the calendar. */}
{!cal.isShared && (
<button
type="button"
onClick={() => setAlertsEditingId(cal.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t('default_alerts')}
aria-label={t('default_alerts')}
>
<Bell className="w-3.5 h-3.5" />
</button>
)}
{cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (
<button
type="button"
+100
View File
@@ -5,6 +5,12 @@ import {
getEffectiveAlerts,
buildAlertKey,
getPendingAlerts,
getCalendarDefaultAlerts,
offsetToAlertUnit,
describeAlert,
alertsToRows,
rowsToAlerts,
newAlertRow,
} from '../calendar-alerts';
import type {
CalendarEvent,
@@ -224,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()];
@@ -237,6 +250,93 @@ describe('getEffectiveAlerts', () => {
});
});
describe('getCalendarDefaultAlerts', () => {
const withTime = { d1: makeAlert() };
const withoutTime = { d2: makeAlert({ action: 'email' }) };
const calendar = makeCalendar({ defaultAlertsWithTime: withTime, defaultAlertsWithoutTime: withoutTime });
it('picks the timed map for timed events and the all-day map for all-day events', () => {
expect(getCalendarDefaultAlerts(calendar, false)).toBe(withTime);
expect(getCalendarDefaultAlerts(calendar, true)).toBe(withoutTime);
});
it('returns null for a missing calendar or an empty map', () => {
expect(getCalendarDefaultAlerts(undefined, false)).toBeNull();
expect(getCalendarDefaultAlerts(makeCalendar({ defaultAlertsWithTime: {} }), false)).toBeNull();
});
});
describe('offsetToAlertUnit', () => {
it('reads offsets before the start', () => {
expect(offsetToAlertUnit('-PT15M')).toEqual({ value: 15, unit: 'minutes' });
expect(offsetToAlertUnit('-PT120M')).toEqual({ value: 2, unit: 'hours' });
expect(offsetToAlertUnit('-PT9H')).toEqual({ value: 9, unit: 'hours' });
expect(offsetToAlertUnit('-P2D')).toEqual({ value: 2, unit: 'days' });
expect(offsetToAlertUnit('-P1W')).toEqual({ value: 1, unit: 'weeks' });
expect(offsetToAlertUnit('PT0S')).toEqual({ value: 0, unit: 'at_time' });
});
it('refuses offsets after the start and compound offsets', () => {
expect(offsetToAlertUnit('PT15M')).toBeNull();
expect(offsetToAlertUnit('PT9H')).toBeNull();
expect(offsetToAlertUnit('-P1DT2H')).toBeNull();
});
});
describe('describeAlert', () => {
it('keeps the action of display and email alerts', () => {
expect(describeAlert(makeAlert())).toEqual({ value: 5, unit: 'minutes', action: 'display' });
expect(describeAlert(makeAlert({ action: 'email' }))).toEqual({ value: 5, unit: 'minutes', action: 'email' });
});
it('accepts a trigger without relativeTo, as Stalwart returns it', () => {
const alert = makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } as CalendarEventAlert['trigger'] });
expect(describeAlert(alert)).toEqual({ value: 1, unit: 'hours', action: 'display' });
});
it('returns null for alerts the rows cannot show', () => {
expect(describeAlert(makeAlert({ trigger: { '@type': 'AbsoluteTrigger', when: '2026-03-01T08:00:00Z' } }))).toBeNull();
expect(describeAlert(makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'end' } }))).toBeNull();
expect(describeAlert(makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: 'PT5M', relativeTo: 'start' } }))).toBeNull();
});
});
describe('alertsToRows / rowsToAlerts', () => {
it('round-trips rows and keeps the alerts it cannot show unchanged', () => {
const absolute = makeAlert({ trigger: { '@type': 'AbsoluteTrigger', when: '2026-03-01T08:00:00Z' }, action: 'email' });
const afterEnd = makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: 'PT10M', relativeTo: 'end' } });
const { rows, preserved } = alertsToRows({
a: makeAlert({ action: 'email', trigger: { '@type': 'OffsetTrigger', offset: '-P1D', relativeTo: 'start' } }),
abs: absolute,
end: afterEnd,
});
expect(rows.map(({ value, unit, action }) => ({ value, unit, action }))).toEqual([{ value: 1, unit: 'days', action: 'email' }]);
expect(preserved).toEqual({ abs: absolute, end: afterEnd });
const saved = rowsToAlerts(rows, preserved);
expect(saved.abs).toBe(absolute);
expect(saved.end).toBe(afterEnd);
const built = Object.entries(saved).filter(([k]) => k !== 'abs' && k !== 'end');
expect(built).toHaveLength(1);
expect(built[0][1]).toMatchObject({ action: 'email', trigger: { '@type': 'OffsetTrigger', offset: '-P1D', relativeTo: 'start' } });
});
it('writes each row with its own action and skips rows without an amount', () => {
const saved = rowsToAlerts([newAlertRow(10, 'minutes'), newAlertRow(2, 'hours', 'email'), newAlertRow(0, 'minutes')]);
expect(Object.values(saved).map((a) => [a.action, (a.trigger as { offset: string }).offset])).toEqual([
['display', '-PT10M'],
['email', '-PT2H'],
]);
});
it('does not overwrite a preserved alert that already uses a generated key', () => {
const kept = makeAlert({ trigger: { '@type': 'AbsoluteTrigger', when: '2026-03-01T08:00:00Z' } });
const saved = rowsToAlerts([newAlertRow(5, 'minutes')], { alert1: kept });
expect(saved.alert1).toBe(kept);
expect(saved.alert2).toMatchObject({ trigger: { offset: '-PT5M' } });
});
});
describe('buildAlertKey', () => {
it('builds deterministic key', () => {
const key = buildAlertKey('evt-1', 'alert-1', 12345);
@@ -0,0 +1,35 @@
import { describe, it, expect, vi } from 'vitest';
import { JMAPClient } from '../jmap/client';
import type { Calendar } from '../jmap/types';
type Call = [string, Record<string, unknown>, string];
// #1209: calendar default alerts are edited through updateCalendar, which
// drops every key Stalwart's Calendar/set would reject.
function capturingClient() {
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
vi.spyOn(client as unknown as { getCalendarsAccountId: () => string }, 'getCalendarsAccountId').mockReturnValue('me');
const calls: Call[] = [];
vi.spyOn(client as unknown as { request: (c: Call[]) => Promise<unknown> }, 'request')
.mockImplementation(async (sent: Call[]) => {
calls.push(sent[0]);
return { methodResponses: [['Calendar/set', { updated: { cal1: null } }, '0']] };
});
return { client, calls };
}
describe('updateCalendar default alerts', () => {
it('sends both default alert maps, including email alerts', async () => {
const { client, calls } = capturingClient();
const withTime = {
a: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'email', acknowledged: null, relatedTo: null },
} as Calendar['defaultAlertsWithTime'];
await client.updateCalendar('cal1', {
defaultAlertsWithTime: withTime,
defaultAlertsWithoutTime: null,
myRights: {} as Calendar['myRights'],
});
const update = (calls[0][1].update as Record<string, Record<string, unknown>>).cal1;
expect(update).toEqual({ defaultAlertsWithTime: withTime, defaultAlertsWithoutTime: null });
});
});
+120
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];
@@ -90,6 +96,120 @@ export function getEffectiveAlerts(
return calendar.defaultAlertsWithTime;
}
/** The calendar's default alerts for a timed or an all-day event. */
export function getCalendarDefaultAlerts(
calendar: Calendar | null | undefined,
allDay: boolean
): Record<string, CalendarEventAlert> | null {
if (!calendar) return null;
const alerts = allDay ? calendar.defaultAlertsWithoutTime : calendar.defaultAlertsWithTime;
return alerts && Object.keys(alerts).length > 0 ? alerts : null;
}
export type AlertUnit = 'at_time' | 'minutes' | 'hours' | 'days' | 'weeks';
export type AlertAction = CalendarEventAlert['action'];
/** One editable reminder: an amount and unit before the start, and how it fires. */
export interface AlertRow {
id: string;
value: number;
unit: AlertUnit;
action: AlertAction;
}
let alertRowSeq = 0;
export function newAlertRow(value: number, unit: AlertUnit, action: AlertAction = 'display'): AlertRow {
alertRowSeq += 1;
return { id: `r${alertRowSeq}`, value, unit, action };
}
export function alertRowToOffset(row: Pick<AlertRow, 'value' | 'unit'>): string | null {
if (row.unit === 'at_time') return 'PT0S';
const v = Math.max(0, Math.floor(row.value));
if (!Number.isFinite(v) || v <= 0) return null;
switch (row.unit) {
case 'minutes': return `-PT${v}M`;
case 'hours': return `-PT${v}H`;
case 'days': return `-P${v}D`;
case 'weeks': return `-P${v}W`;
}
}
/**
* Reads an offset at or before the start as an amount and unit. Offsets after
* the start and compound ones (-P1DT2H) return null: the rows can't show them.
*/
export function offsetToAlertUnit(offset: string): { value: number; unit: AlertUnit } | null {
if (/^-?PT?0[SMHDW]$/.test(offset)) return { value: 0, unit: 'at_time' };
if (!offset.startsWith('-')) return null;
let m = offset.match(/^-P(\d+)W$/);
if (m) return { value: parseInt(m[1], 10), unit: 'weeks' };
m = offset.match(/^-P(\d+)D$/);
if (m) return { value: parseInt(m[1], 10), unit: 'days' };
m = offset.match(/^-PT(\d+)H$/);
if (m) return { value: parseInt(m[1], 10), unit: 'hours' };
m = offset.match(/^-PT(\d+)M$/);
if (m) {
const mins = parseInt(m[1], 10);
if (mins > 0 && mins % 1440 === 0) return { value: mins / 1440, unit: 'days' };
if (mins > 0 && mins % 60 === 0) return { value: mins / 60, unit: 'hours' };
return { value: mins, unit: 'minutes' };
}
return null;
}
/**
* Describes an alert as a row, or null when the row editor can't represent it
* (absolute triggers, offsets relative to the end or after the start).
*/
export function describeAlert(alert: CalendarEventAlert): Omit<AlertRow, 'id'> | null {
if (alert.trigger['@type'] !== 'OffsetTrigger') return null;
// Stalwart leaves relativeTo out when it is the default, `start`.
if (alert.trigger.relativeTo === 'end') return null;
if (alert.action !== 'display' && alert.action !== 'email') return null;
const unit = offsetToAlertUnit(alert.trigger.offset);
if (!unit) return null;
return { ...unit, action: alert.action };
}
/** Splits an alert map into editable rows and the alerts to keep as they are. */
export function alertsToRows(alerts: Record<string, CalendarEventAlert> | null | 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 = describeAlert(alert);
if (row) rows.push(newAlertRow(row.value, row.unit, row.action));
else preserved[id] = alert;
}
return { rows, preserved };
}
/** Builds the alert map to save from the rows plus the preserved alerts. */
export function rowsToAlerts(
rows: AlertRow[],
preserved: Record<string, CalendarEventAlert> = {}
): Record<string, CalendarEventAlert> {
const built: Record<string, CalendarEventAlert> = { ...preserved };
let idx = 0;
for (const row of rows) {
const offset = alertRowToOffset(row);
if (offset === null) continue;
let key = `alert${++idx}`;
while (key in built) key = `alert${++idx}`;
built[key] = {
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset, relativeTo: 'start' },
action: row.action,
acknowledged: null,
relatedTo: null,
};
}
return built;
}
export function buildAlertKey(eventId: string, alertId: string, fireTimeMs: number): string {
return `${eventId}:${alertId}:${fireTimeMs}`;
}
+17 -2
View File
@@ -3249,7 +3249,16 @@
"add": "Add reminder",
"remove": "Remove reminder",
"amount": "Reminder amount",
"unit": "Reminder unit"
"unit": "Reminder unit",
"action": "Reminder type",
"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}}",
"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",
@@ -3450,7 +3459,13 @@
"kind_events": "Events",
"kind_tasks": "Tasks",
"kind_both": "Events and tasks",
"kind_hint": "Decides which apps see this calendar when it is synced over CalDAV. It cannot be changed later."
"kind_hint": "Decides which apps see this calendar when it is synced over CalDAV. It cannot be changed later.",
"default_alerts": "Default reminders",
"default_alerts_title": "Default reminders for \"{name}\"",
"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.",
"default_alerts_timed": "Events with a time",
"default_alerts_all_day": "All-day events",
"default_alerts_updated": "Default reminders updated"
},
"subscription": {
"title": "iCal Subscription",