diff --git a/components/calendar/__tests__/task-modal-alerts.test.tsx b/components/calendar/__tests__/task-modal-alerts.test.tsx index 333836ed7..29ae44617 100644 --- a/components/calendar/__tests__/task-modal-alerts.test.tsx +++ b/components/calendar/__tests__/task-modal-alerts.test.tsx @@ -80,3 +80,48 @@ describe('TaskModal alert preservation (#504)', () => { expect(Object.keys(patch.alerts!)).toEqual(['a-abs']); }); }); + +// Stalwart 1.0 shows a task's calendar defaults in `alerts`, under the +// default alerts' ids, while `useDefaultAlerts` is on, and drops alerts with +// those ids on save. +describe('TaskModal alerts of a task that uses the calendar defaults', () => { + const defaultsTask = () => ({ + ...makeTask(), + useDefaultAlerts: true, + alerts: { + 'default-15': { + '@type': 'Alert', + trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, + action: 'display', + acknowledged: null, + relatedTo: null, + }, + }, + } as unknown as CalendarTask); + + it('turns the defaults off and moves the alerts to ids of their own when the alert changes on Stalwart 1.0', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.change(screen.getByDisplayValue('tasks.alert_15min'), { target: { value: '60' } }); + fireEvent.click(screen.getByText('tasks.save')); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + const patch = onSave.mock.calls[0][0] as Partial; + expect(patch.useDefaultAlerts).toBe(false); + expect(Object.keys(patch.alerts!)).toEqual(['alert1']); + expect(patch.alerts!.alert1.trigger).toMatchObject({ offset: '-PT60M' }); + }); + + it('leaves the defaults alone when the alert was not touched', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByText('tasks.save')); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + const patch = onSave.mock.calls[0][0] as Partial; + expect(patch).not.toHaveProperty('alerts'); + expect(patch).not.toHaveProperty('useDefaultAlerts'); + }); +}); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 29b9c1cfd..2a0788f0f 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -24,6 +24,7 @@ import { buildParticipantMap, } from "@/lib/calendar-participants"; import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calendar-editability"; +import { withOwnAlertIds } from "@/lib/calendar-alerts"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useSettingsStore } from "@/stores/settings-store"; import { generateUUID } from "@/lib/utils"; @@ -115,10 +116,12 @@ type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "cu type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks"; -interface AlertRow { +export interface AlertRow { id: string; value: number; unit: AlertUnit; + /** The event alert the row shows, and what the row read when loaded. */ + source?: { alertId: string; value: number; unit: AlertUnit }; } let alertRowSeq = 0; @@ -159,6 +162,82 @@ function offsetToAlertRow(offset: string): AlertRow | null { return null; } +/** + * The editor rows for an event's alerts, and the alerts the rows cannot + * show (absolute triggers, email actions, unusual offsets), which a save + * keeps as they are. + */ +export function alertRowsOf(alerts: CalendarEvent["alerts"] | undefined): { + rows: AlertRow[]; + preserved: Record; +} { + const rows: AlertRow[] = []; + const preserved: Record = {}; + for (const [id, alert] of Object.entries(alerts ?? {})) { + const row = alert.trigger["@type"] === "OffsetTrigger" && alert.action === "display" + ? offsetToAlertRow(alert.trigger.offset) + : null; + if (!row) { + preserved[id] = alert; + continue; + } + rows.push({ ...row, source: { alertId: id, value: row.value, unit: row.unit } }); + } + return { rows, preserved }; +} + +/** + * What a save sends for the alerts: nothing when they are as the event had + * them. Rows the user left alone keep their alert as it was, under its id; + * new and changed rows become new alerts. + * + * On an event that uses its calendar's default alerts, a change turns them + * off for the event, as the native app does: while `useDefaultAlerts` is on, + * Stalwart 1.0 fills the defaults into `alerts` and shows those instead of + * the event's own, so a change would not stick. The alerts then get ids of + * their own (withOwnAlertIds), away from the event's and the calendar's + * default ids (`reservedIds`). 0.16 stores the alerts either way. + */ +export function buildAlertsPatch( + event: Pick | null | undefined, + rows: AlertRow[], + preserved: Record, + reservedIds: Iterable = [], +): Partial> { + const original = event?.alerts ?? {}; + const kept: Record = { ...preserved }; + const added: CalendarEventAlert[] = []; + for (const row of rows) { + const offset = alertRowToOffset(row); + if (offset === null) continue; + const { source } = row; + if (source && row.value === source.value && row.unit === source.unit && original[source.alertId]) { + kept[source.alertId] = original[source.alertId]; + continue; + } + added.push({ + "@type": "Alert", + trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" }, + action: "display", + acknowledged: null, + relatedTo: null, + }); + } + + const keptAll = Object.keys(original).every((id) => id in kept); + if (added.length === 0 && keptAll) return {}; + + const taken = [...Object.keys(original), ...reservedIds]; + const alerts = event?.useDefaultAlerts + ? withOwnAlertIds([...Object.values(kept), ...added], taken) + : { ...kept, ...withOwnAlertIds(added, taken) }; + const patch: Partial> = { + alerts: Object.keys(alerts).length > 0 ? alerts : null, + }; + if (event?.useDefaultAlerts) patch.useDefaultAlerts = false; + return patch; +} + function formatAlertRowLabel( row: { value: number; unit: AlertUnit }, t: ReturnType @@ -386,24 +465,11 @@ export function EventModal({ () => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null), [customRule, t, locale] ); + // Alerts this UI can't represent survive a save as they are. 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); - } + const { rows, preserved } = alertRowsOf(event?.alerts); + preservedAlertsRef.current = preserved; return rows; }); @@ -594,26 +660,10 @@ 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; - } + const defaultAlertIds = calendars + .filter((c) => c.id === calendarId || event?.calendarIds?.[c.id]) + .flatMap((c) => [...Object.keys(c.defaultAlertsWithTime ?? {}), ...Object.keys(c.defaultAlertsWithoutTime ?? {})]); + Object.assign(data, buildAlertsPatch(event, alertRows, preservedAlertsRef.current, defaultAlertIds)); if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) { const organizerEmail = currentUserEmails[0]; @@ -644,7 +694,7 @@ export function EventModal({ } finally { setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving]); const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => { if (!event || !userParticipantId || !onRsvp) return; diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 8e648c490..d9a5468c3 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -8,6 +8,7 @@ import { X, Trash2, CalendarDays, Bell, Flag } from "@/components/icons"; import { format, parseISO } from "date-fns"; import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types"; +import { withOwnAlertIds } from "@/lib/calendar-alerts"; interface TaskModalProps { task?: CalendarTask | null; @@ -134,7 +135,14 @@ export function TaskModal({ relatedTo: null, }; } - data.alerts = Object.keys(merged).length > 0 ? merged : null; + // A task that uses its calendar's default alerts turns them off: + // Stalwart 1.0 shows the defaults in `alerts` instead of the task's + // own while they are on, and drops alerts under the default ids. + const alerts = task?.useDefaultAlerts + ? withOwnAlertIds(Object.values(merged), Object.keys(task.alerts ?? {})) + : merged; + if (task?.useDefaultAlerts) data.useDefaultAlerts = false; + data.alerts = Object.keys(alerts).length > 0 ? alerts : null; } if (isEdit && task) { diff --git a/lib/__tests__/calendar-alerts.test.ts b/lib/__tests__/calendar-alerts.test.ts index c57cd2ba5..0480cdc8c 100644 --- a/lib/__tests__/calendar-alerts.test.ts +++ b/lib/__tests__/calendar-alerts.test.ts @@ -5,10 +5,13 @@ import { getEffectiveAlerts, buildAlertKey, getPendingAlerts, + getPendingTaskAlerts, + withOwnAlertIds, } from '../calendar-alerts'; import type { CalendarEvent, CalendarEventAlert, + CalendarTask, Calendar, } from '@/lib/jmap/types'; @@ -421,3 +424,43 @@ describe('getPendingAlerts', () => { expect(result[0].calendarName).toBeNull(); }); }); + +// Stalwart 1.0 fills a calendar's default alerts into `alerts` while +// `useDefaultAlerts` is on, under the default alerts' own ids (for tasks as +// well). 0.16 copied the defaults into `alerts` once, when the event was +// created. Either way each alert fires once. +describe('default alerts filled in by the server', () => { + const eventStart = new Date('2026-03-01T10:00:00Z').getTime(); + const defaults = { 'default-5': makeAlert(), 'default-mail': makeAlert({ action: 'email' }) }; + const calendars = [makeCalendar({ defaultAlertsWithTime: defaults })]; + + it('fires each default alert of an event once on Stalwart 1.0', () => { + const event = makeEvent({ useDefaultAlerts: true, alerts: { ...defaults } }); + const pending = getPendingAlerts([event], calendars, new Set(), eventStart - 5 * 60 * 1000 + 1000); + expect(pending.map((p) => p.alertId)).toEqual(['default-5']); + }); + + it('fires each default alert of an event once on 0.16', () => { + // Copied in at create time, under ids of 0.16's own. + const event = makeEvent({ useDefaultAlerts: true, alerts: { copied: makeAlert() } }); + const pending = getPendingAlerts([event], calendars, new Set(), eventStart - 5 * 60 * 1000 + 1000); + expect(pending.map((p) => p.alertId)).toEqual(['default-5']); + }); + + it('fires the default alerts filled into a task once on Stalwart 1.0', () => { + const task = { + id: 'task-1', '@type': 'Task', title: 'Pay rent', calendarIds: { 'cal-1': true }, + due: '2026-03-01T10:00:00Z', progress: 'needs-action', useDefaultAlerts: true, alerts: { ...defaults }, + } as unknown as CalendarTask; + const pending = getPendingTaskAlerts([task], calendars, new Set(), eventStart - 5 * 60 * 1000 + 1000); + expect(pending.map((p) => p.alertId)).toEqual(['default-5']); + }); +}); + +describe('withOwnAlertIds', () => { + it('numbers the alerts past the ids that are taken', () => { + const [a, b] = [makeAlert({ trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'start' } }), makeAlert()]; + expect(withOwnAlertIds([a, b], ['alert1', 'default-5'])).toEqual({ alert2: a, alert3: b }); + expect(withOwnAlertIds([], ['alert1'])).toEqual({}); + }); +}); diff --git a/lib/__tests__/event-modal-alerts.test.ts b/lib/__tests__/event-modal-alerts.test.ts new file mode 100644 index 000000000..2bf82a980 --- /dev/null +++ b/lib/__tests__/event-modal-alerts.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest'; +import { alertRowsOf, buildAlertsPatch, type AlertRow } from '@/components/calendar/event-modal'; +import type { CalendarEvent, CalendarEventAlert } from '@/lib/jmap/types'; + +// What the event editor saves for the alerts. It used to rename every alert +// to alert1..N and send them on every save. On Stalwart 1.0 that loses alert +// edits on events that use their calendar's default alerts: +// - on get, while `useDefaultAlerts` is on, the server shows the calendar's +// defaults in `alerts` (under the default alerts' ids) instead of the +// event's own (calendar_event/get.rs, DefaultAlertsView::Replace); +// - on save, it drops every alert whose id is a default id +// (calendar_event/set.rs, strip_default_alerts). +// 0.16 copied the defaults into the event once, at create, and stores +// whatever is sent. + +const offsetAlert = (offset: string, extra: Partial = {}): CalendarEventAlert => ({ + '@type': 'Alert', + trigger: { '@type': 'OffsetTrigger', offset, relativeTo: 'start' }, + action: 'display', + acknowledged: null, + relatedTo: null, + ...extra, +}); + +type Stored = { own: Record; useDefaultAlerts: boolean }; +type Patch = Partial>; + +const DEFAULTS = { 'default-15': offsetAlert('-PT15M') }; + +/** What Stalwart 1.0 answers for the event's alerts. */ +function get10(stored: Stored): Pick { + return { useDefaultAlerts: stored.useDefaultAlerts, alerts: stored.useDefaultAlerts ? { ...DEFAULTS } : { ...stored.own } }; +} + +/** How Stalwart 1.0 stores an update of the event's alerts. */ +function update10(stored: Stored, patch: Patch): Stored { + const viewDefaults: Record = stored.useDefaultAlerts ? DEFAULTS : {}; + let alerts = { ...stored.own, ...viewDefaults }; + if ('alerts' in patch) alerts = { ...(patch.alerts ?? {}) }; + const useDefaultAlerts = 'useDefaultAlerts' in patch ? !!patch.useDefaultAlerts : stored.useDefaultAlerts; + const newDefaults: Record = useDefaultAlerts ? DEFAULTS : {}; + for (const id of Object.keys(alerts)) { + if (id in viewDefaults || id in newDefaults) delete alerts[id]; + } + return { own: alerts, useDefaultAlerts }; +} + +const offsets = (alerts: CalendarEvent['alerts']) => Object.values(alerts ?? {}) + .map((a) => (a.trigger as { offset: string }).offset) + .sort(); + +/** The editor's rows for `alerts`, with `edit` applied to them. */ +function edit(event: Pick, change: (rows: AlertRow[]) => AlertRow[]): Patch { + const { rows, preserved } = alertRowsOf(event.alerts); + return buildAlertsPatch(event, change(rows), preserved, Object.keys(DEFAULTS)); +} + +const newRow = (value: number, unit: AlertRow['unit']): AlertRow => ({ id: `new-${value}-${unit}`, value, unit }); + +describe('saving the alerts of an event that uses its calendar’s default alerts', () => { + it('sends nothing when they were not touched, on Stalwart 1.0', () => { + expect(edit(get10({ own: {}, useDefaultAlerts: true }), (rows) => rows)).toEqual({}); + }); + + it('keeps a changed alert on Stalwart 1.0 by turning the defaults off', () => { + let stored: Stored = { own: {}, useDefaultAlerts: true }; + const patch = edit(get10(stored), ([row]) => [{ ...row, value: 30 }]); + + expect(patch.useDefaultAlerts).toBe(false); + stored = update10(stored, patch); + expect(offsets(get10(stored).alerts)).toEqual(['-PT30M']); + + // What was sent before: the same alerts renamed, with the defaults left + // on. Stalwart 1.0 keeps showing the defaults instead. + const before = update10({ own: {}, useDefaultAlerts: true }, { alerts: { alert1: offsetAlert('-PT30M') } }); + expect(offsets(get10(before).alerts)).toEqual(['-PT15M']); + }); + + it('keeps an untouched default next to a new alert on Stalwart 1.0, under ids of their own', () => { + let stored: Stored = { own: {}, useDefaultAlerts: true }; + const patch = edit(get10(stored), (rows) => [...rows, newRow(1, 'hours')]); + + expect(Object.keys(patch.alerts ?? {})).not.toContain('default-15'); + stored = update10(stored, patch); + expect(offsets(get10(stored).alerts)).toEqual(['-PT15M', '-PT1H']); + }); + + it('removes every alert on Stalwart 1.0 when the user removes them', () => { + let stored: Stored = { own: {}, useDefaultAlerts: true }; + const patch = edit(get10(stored), () => []); + + expect(patch).toEqual({ alerts: null, useDefaultAlerts: false }); + stored = update10(stored, patch); + expect(get10(stored).alerts).toEqual({}); + }); + + it('turns the defaults off on 0.16 too, where the event holds its own copy of them', () => { + // 0.16 copied the calendar's defaults into the event at create time. + const event = { useDefaultAlerts: true, alerts: { copied: offsetAlert('-PT15M') } }; + const patch = edit(event, ([row]) => [{ ...row, value: 45 }]); + + expect(patch).toEqual({ useDefaultAlerts: false, alerts: { alert1: offsetAlert('-PT45M') } }); + }); +}); + +describe('saving the alerts of an event with alerts of its own', () => { + const event = { + useDefaultAlerts: false, + alerts: { + a1: offsetAlert('-PT15M'), + // Before the end, which the editor cannot show exactly. + a2: offsetAlert('PT10M', { trigger: { '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'end' } }), + abs: { ...offsetAlert('-PT1H'), trigger: { '@type': 'AbsoluteTrigger', when: '2026-09-10T08:00:00Z' } }, + } as Record, + }; + + it('sends nothing when they were not touched', () => { + expect(edit(event, (rows) => rows)).toEqual({}); + }); + + it('keeps untouched alerts as they were, under their ids, and adds new ones under new ids', () => { + const patch = edit(event, (rows) => [...rows, newRow(2, 'days')]); + + expect(patch).not.toHaveProperty('useDefaultAlerts'); + expect(patch.alerts).toEqual({ + a1: event.alerts.a1, + a2: event.alerts.a2, + abs: event.alerts.abs, + alert1: offsetAlert('-P2D'), + }); + }); + + it('sends the remaining alerts when one is removed, and null when all are', () => { + expect(edit(event, (rows) => rows.filter((row) => row.source?.alertId !== 'a1')).alerts) + .toEqual({ a2: event.alerts.a2, abs: event.alerts.abs }); + const onlyRows = { useDefaultAlerts: false, alerts: { a1: offsetAlert('-PT15M') } }; + expect(edit(onlyRows, () => [])).toEqual({ alerts: null }); + }); +}); + +describe('saving the alerts of a new event', () => { + it('sends the rows as new alerts, and nothing without rows', () => { + expect(buildAlertsPatch(null, [newRow(10, 'minutes')], {})).toEqual({ alerts: { alert1: offsetAlert('-PT10M') } }); + expect(buildAlertsPatch(null, [], {})).toEqual({}); + }); +}); diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts index f7b4f4c9b..e6a84cc3a 100644 --- a/lib/calendar-alerts.ts +++ b/lib/calendar-alerts.ts @@ -69,6 +69,12 @@ export function computeFireTime( return baseTime + offsetMs; } +/** + * The alerts an event fires: the calendar's default alerts while the event + * uses them, its own otherwise - never both. Stalwart 1.0 also fills the + * defaults into the `alerts` of such an event, under the default alerts' + * ids; reading one or the other keeps each alert firing once. + */ export function getEffectiveAlerts( event: CalendarEvent, calendars: Calendar[] @@ -94,6 +100,30 @@ export function buildAlertKey(eventId: string, alertId: string, fireTimeMs: numb return `${eventId}:${alertId}:${fireTimeMs}`; } +/** + * `alerts` under new ids (alert1, alert2, ...), none of them in `taken`. + * + * For an event or task that turns its calendar's default alerts off: while + * `useDefaultAlerts` is on, Stalwart 1.0 shows the defaults in `alerts` + * under the default alerts' ids, and on save it drops every alert whose id + * is one of those. Alerts the user keeps have to be saved under ids of their + * own, or they vanish with the defaults. + */ +export function withOwnAlertIds( + alerts: CalendarEventAlert[], + taken: Iterable, +): Record { + const used = new Set(taken); + const result: Record = {}; + let n = 0; + for (const alert of alerts) { + let id: string; + do id = `alert${++n}`; while (used.has(id)); + result[id] = alert; + } + return result; +} + export function getPendingAlerts( events: CalendarEvent[], calendars: Calendar[], diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index b4fcfe858..d1ad56be8 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -848,6 +848,8 @@ export interface CalendarTask { created: string | null; updated: string; recurrenceRules: CalendarRecurrenceRule[] | null; + // While on, Stalwart 1.0 fills the calendar's default alerts into `alerts`. + useDefaultAlerts?: boolean; alerts: Record | null; relatedTo: Record | null; }