From 3e202e73fd8e7c70e3c022c10f6709ff6064d4bc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:41:27 +0200 Subject: [PATCH] feat: list every reminder and the calendar defaults in the event popover #1209 --- .../event-detail-popover-alerts.test.tsx | 74 +++++++++++++++++++ components/calendar/alert-list-editor.tsx | 15 +++- components/calendar/event-detail-popover.tsx | 18 +---- components/calendar/event-modal.tsx | 26 +------ 4 files changed, 93 insertions(+), 40 deletions(-) create mode 100644 components/calendar/__tests__/event-detail-popover-alerts.test.tsx 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/alert-list-editor.tsx b/components/calendar/alert-list-editor.tsx index 7333f4577..ac455f1dd 100644 --- a/components/calendar/alert-list-editor.tsx +++ b/components/calendar/alert-list-editor.tsx @@ -4,9 +4,10 @@ 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 type { Calendar, CalendarEvent, CalendarEventAlert } from "@/lib/jmap/types"; import { describeAlert, + getEffectiveAlerts, newAlertRow, type AlertAction, type AlertRow, @@ -31,6 +32,18 @@ export function formatAlertRowLabel(row: Omit, t: CalendarT): st 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"); 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 800596c73..a01490227 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -9,15 +9,8 @@ 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, AlertSummary, formatAlertRowLabel } from "./alert-list-editor"; -import { - alertsToRows, - describeAlert, - getCalendarDefaultAlerts, - getEffectiveAlerts, - rowsToAlerts, - type AlertRow, -} from "@/lib/calendar-alerts"; +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"; @@ -126,19 +119,6 @@ function formatDurationDisplay(minutes: number): string { return `${h}h${m}min`; } -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(alerts)) { - const row = describeAlert(alert); - 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); @@ -829,7 +809,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, calendars, t); + const alertLabel = formatEventAlertsLabel(event, calendars, t); const eventCalendar = calendars.find(c => event.calendarIds[c.id]); const color = getEventColor(event, eventCalendar);