From 87c6b6762cbf04770812a9c9cba8d2dbae74b208 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:16:05 +0200 Subject: [PATCH 1/7] feat: add helpers for calendar default alerts and email alert rows #1209 --- lib/__tests__/calendar-alerts.test.ts | 93 ++++++++++++++ .../jmap-calendar-default-alerts.test.ts | 35 ++++++ lib/calendar-alerts.ts | 114 ++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 lib/__tests__/jmap-calendar-default-alerts.test.ts diff --git a/lib/__tests__/calendar-alerts.test.ts b/lib/__tests__/calendar-alerts.test.ts index c57cd2ba5..bdc13e64d 100644 --- a/lib/__tests__/calendar-alerts.test.ts +++ b/lib/__tests__/calendar-alerts.test.ts @@ -5,6 +5,12 @@ import { getEffectiveAlerts, buildAlertKey, getPendingAlerts, + getCalendarDefaultAlerts, + offsetToAlertUnit, + describeAlert, + alertsToRows, + rowsToAlerts, + newAlertRow, } from '../calendar-alerts'; import type { CalendarEvent, @@ -237,6 +243,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); diff --git a/lib/__tests__/jmap-calendar-default-alerts.test.ts b/lib/__tests__/jmap-calendar-default-alerts.test.ts new file mode 100644 index 000000000..a4bde7fd8 --- /dev/null +++ b/lib/__tests__/jmap-calendar-default-alerts.test.ts @@ -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]; + +// #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 }, '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>).cal1; + expect(update).toEqual({ defaultAlertsWithTime: withTime, defaultAlertsWithoutTime: null }); + }); +}); diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts index f7b4f4c9b..2d29adc52 100644 --- a/lib/calendar-alerts.ts +++ b/lib/calendar-alerts.ts @@ -90,6 +90,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 | 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): 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 | 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 | null | undefined): { + rows: AlertRow[]; + preserved: Record; +} { + const rows: AlertRow[] = []; + const preserved: Record = {}; + 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 = {} +): Record { + const built: Record = { ...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}`; } From 4e983e3e4e877cd3e70c202735271e4887aa8027 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:20:08 +0200 Subject: [PATCH 2/7] feat: choose notification or email for each event reminder #1209 --- .../__tests__/event-modal-alerts.test.tsx | 104 ++++++++++ components/calendar/alert-list-editor.tsx | 127 ++++++++++++ components/calendar/event-modal.tsx | 180 ++---------------- locales/en/common.json | 7 +- 4 files changed, 252 insertions(+), 166 deletions(-) create mode 100644 components/calendar/__tests__/event-modal-alerts.test.tsx create mode 100644 components/calendar/alert-list-editor.tsx 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..b59522588 --- /dev/null +++ b/components/calendar/__tests__/event-modal-alerts.test.tsx @@ -0,0 +1,104 @@ +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. + +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 }); + }); +}); diff --git a/components/calendar/alert-list-editor.tsx b/components/calendar/alert-list-editor.tsx new file mode 100644 index 000000000..7245d06bc --- /dev/null +++ b/components/calendar/alert-list-editor.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Input } from "@/components/ui/input"; +import { Trash2, Plus } from "@/components/icons"; +import { + 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; +} + +interface AlertListEditorProps { + rows: AlertRow[]; + onChange: (rows: AlertRow[]) => void; + /** Alerts the rows can't show (absolute, after the start); saved unchanged. */ + keptCount?: number; + disabled?: boolean; +} + +/** + * 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 }: 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 })}

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

{t("alerts.none")}

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

{t("alerts.calendar_defaults")}

+ + +
+ ) : ( + setUseDefaultAlerts(true)} + className="inline-flex items-center gap-1 text-sm text-primary hover:underline" + > + {t("alerts.use_defaults")} + + } + /> + )}
{attendees.length > 0 && ( diff --git a/lib/__tests__/calendar-alerts.test.ts b/lib/__tests__/calendar-alerts.test.ts index bdc13e64d..484ece3a7 100644 --- a/lib/__tests__/calendar-alerts.test.ts +++ b/lib/__tests__/calendar-alerts.test.ts @@ -230,6 +230,13 @@ describe('getEffectiveAlerts', () => { expect(getEffectiveAlerts(event, calendars)).toBe(defaultAlerts); }); + it('prefers the alerts the server copied into a defaults event over the current calendar defaults', () => { + const copied = { k1: makeAlert({ action: 'email' }) }; + const event = makeEvent({ useDefaultAlerts: true, alerts: copied }); + const calendars = [makeCalendar({ defaultAlertsWithTime: { d1: makeAlert() } })]; + expect(getEffectiveAlerts(event, calendars)).toBe(copied); + }); + it('returns null when calendar not found', () => { const event = makeEvent({ useDefaultAlerts: true, calendarIds: { 'missing': true } }); const calendars = [makeCalendar()]; diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts index 2d29adc52..ebcd2d36b 100644 --- a/lib/calendar-alerts.ts +++ b/lib/calendar-alerts.ts @@ -76,6 +76,12 @@ export function getEffectiveAlerts( if (!event.useDefaultAlerts) { return event.alerts; } + // Stalwart copies the calendar's defaults into the event when it is + // created and later default changes don't reach it. Its copy is what the + // server (email alerts) and other clients fire, so follow it when present. + if (event.alerts && Object.keys(event.alerts).length > 0) { + return event.alerts; + } if (!event.calendarIds) return null; const calendarId = Object.keys(event.calendarIds)[0]; diff --git a/locales/en/common.json b/locales/en/common.json index 3deee60d9..b91daf038 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3248,7 +3248,11 @@ "action_display": "Notification", "action_email": "Email", "by_email": "{label} by email", - "kept": "{count, plural, one {# more reminder that can't be edited here is kept} other {# more reminders that can't be edited here are kept}}" + "kept": "{count, plural, one {# more reminder that can't be edited here is kept} other {# more reminders that can't be edited here are kept}}", + "other": "{count, plural, one {# other reminder} other {# other reminders}}", + "calendar_defaults": "Calendar defaults", + "customize": "Customize", + "use_defaults": "Use calendar defaults" }, "settings": { "limit_hours": "Limit visible hours", From 1730d7bab461a362fcabe76bacce8cb2dc5f2ffd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:30:51 +0200 Subject: [PATCH 4/7] feat: set default reminders for each calendar in calendar management #1209 --- .../calendar-default-alerts-settings.test.tsx | 98 ++++++++++++++ .../settings/calendar-management-settings.tsx | 125 +++++++++++++++++- locales/en/common.json | 8 +- 3 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 components/settings/__tests__/calendar-default-alerts-settings.test.tsx 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..c80aea02b --- /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 null once its last reminder is removed', 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: null, defaultAlertsWithoutTime: null }); + }); +}); diff --git a/components/settings/calendar-management-settings.tsx b/components/settings/calendar-management-settings.tsx index 1dfddc219..5c88d38d2 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,86 @@ 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); + + const toMap = (rows: AlertRow[], preserved: typeof timed.preserved) => { + const map = rowsToAlerts(rows, preserved); + return Object.keys(map).length > 0 ? map : null; + }; + const submit = () => onSave({ + defaultAlertsWithTime: toMap(timedRows, timed.preserved), + defaultAlertsWithoutTime: toMap(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 +250,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 +412,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 +521,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 +669,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) && (