diff --git a/components/calendar/__tests__/event-detail-popover-alerts.test.tsx b/components/calendar/__tests__/event-detail-popover-alerts.test.tsx new file mode 100644 index 000000000..ff7580963 --- /dev/null +++ b/components/calendar/__tests__/event-detail-popover-alerts.test.tsx @@ -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) => + 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) { + 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( + , + ); +} + +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(); + }); +}); diff --git a/components/calendar/__tests__/event-modal-alerts.test.tsx b/components/calendar/__tests__/event-modal-alerts.test.tsx new file mode 100644 index 000000000..2e08156ad --- /dev/null +++ b/components/calendar/__tests__/event-modal-alerts.test.tsx @@ -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) => + 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 { + 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(); + return onSave; +} + +async function savedData(onSave: ReturnType): Promise> { + 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( + , + ); + 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('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(); + }); +}); diff --git a/components/calendar/alert-list-editor.tsx b/components/calendar/alert-list-editor.tsx new file mode 100644 index 000000000..ac455f1dd --- /dev/null +++ b/components/calendar/alert-list-editor.tsx @@ -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; + +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, 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 | 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; +} + +/** + * 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>) => { + onChange(rows.map((r) => (r.id === id ? { ...r, ...patch } : r))); + }; + + return ( +
+ {rows.length === 0 && keptCount === 0 ? ( +

{t("alerts.none")}

+ ) : ( +
+ {rows.map((row) => ( +
+ {row.unit !== "at_time" && ( + { + 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")} + /> + )} + + + +
+ ))} + {keptCount > 0 && ( +

{t("alerts.kept", { count: keptCount })}

+ )} +
+ )} +
+ + {extraAction} +
+
+ ); +} diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 61590c02c..e7c573d4c 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -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): 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, 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(() => { diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index ee4b98240..340ba2e52 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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 -): 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): 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, 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>({}); - const [alertRows, setAlertRows] = useState(() => { - 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>(initialAlerts.preserved); + const [alertRows, setAlertRows] = useState(initialAlerts.rows); - const addAlertRow = useCallback(() => { - setAlertRows((prev) => [...prev, newAlertRow(10, "minutes")]); - }, []); - const updateAlertRow = useCallback((id: string, patch: Partial>) => { - 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 = { ...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({
- {alertRows.length === 0 ? ( -

{t("alerts.none")}

- ) : ( -
- {alertRows.map((row) => ( -
- {row.unit !== "at_time" && ( - { - 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")} - /> - )} - - -
- ))} + {useDefaultAlerts ? ( +
+

{t("alerts.calendar_defaults")}

+ +
+ ) : ( + { setUseDefaultAlerts(true); setRefreshDefaults(true); }} + className="inline-flex items-center gap-1 text-sm text-primary hover:underline" + > + {t("alerts.use_defaults")} + + } + /> )} -
{attendees.length > 0 && ( diff --git a/components/settings/__tests__/calendar-default-alerts-settings.test.tsx b/components/settings/__tests__/calendar-default-alerts-settings.test.tsx new file mode 100644 index 000000000..d1ff6ac1f --- /dev/null +++ b/components/settings/__tests__/calendar-default-alerts-settings.test.tsx @@ -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 { + 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; + +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(); + expect(screen.getAllByLabelText('default_alerts')).toHaveLength(1); + }); + + it('saves both lists with their actions and keeps alerts it cannot show', async () => { + render(); + 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(); + 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: {} }); + }); +}); diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 1dfddc219..b6e9d6681 100644 --- a/components/settings/calendar-management-settings.tsx +++ b/components/settings/calendar-management-settings.tsx @@ -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; + +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(timed.rows); + const [allDayRows, setAllDayRows] = useState(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 ( +
+
+

{t('default_alerts_title', { name: calendar.name })}

+

{t('default_alerts_hint')}

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ ); +} + 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(null); + const [alertsEditingId, setAlertsEditingId] = useState(null); const [deletingId, setDeletingId] = useState(null); const [clearingId, setClearingId] = useState(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 ( + handleDefaultAlertsSave(cal.id, updates)} + onCancel={() => setAlertsEditingId(null)} + isLoading={isLoading} + /> + ); + } + if (deletingId === cal.id) { return (
@@ -560,6 +666,18 @@ export function CalendarManagementSettings() { > + {/* The owner's defaults; a sharee can't write the calendar. */} + {!cal.isShared && ( + + )} {cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (