feat: list every reminder and the calendar defaults in the event popover #1209

This commit is contained in:
Linus Rath
2026-10-13 15:56:10 +02:00
parent 1730d7bab4
commit 3e202e73fd
4 changed files with 93 additions and 40 deletions
@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { EventDetailPopover } from '../event-detail-popover';
import type { Calendar, CalendarEvent, CalendarEventAlert } from '@/lib/jmap/types';
// #1209: the popover listed only the first alert, as "before" even for email
// alerts, and nothing for an event on its calendar's defaults.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
values ? `${key}(${Object.values(values).join(',')})` : key,
useLocale: () => 'en',
useFormatter: () => ({
dateTime: (d: Date | string) => String(d),
relativeTime: (d: Date | string) => String(d),
number: (n: number) => String(n),
}),
}));
const alert = (offset: string, action: CalendarEventAlert['action']): CalendarEventAlert => ({
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset, relativeTo: 'start' },
action,
acknowledged: null,
relatedTo: null,
});
const calendar = {
id: 'cal-1',
name: 'Work',
myRights: { mayWriteAll: true, mayWriteOwn: true },
defaultAlertsWithTime: { d1: alert('-PT30M', 'display') },
defaultAlertsWithoutTime: null,
} as unknown as Calendar;
function renderPopover(overrides: Partial<CalendarEvent>) {
const event = {
id: 'evt-1',
calendarIds: { 'cal-1': true },
title: 'Review',
start: '2026-11-20T10:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
showWithoutTime: false,
participants: null,
alerts: null,
useDefaultAlerts: false,
...overrides,
} as unknown as CalendarEvent;
render(
<EventDetailPopover
event={event}
calendar={calendar}
anchorRect={new DOMRect(100, 100, 50, 20)}
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onClose={vi.fn()}
onSaveNote={vi.fn()}
/>,
);
}
describe('EventDetailPopover reminders', () => {
it('lists every alert and marks email alerts', () => {
renderPopover({ alerts: { a: alert('-PT15M', 'display'), b: alert('-PT1H', 'email') } });
expect(screen.getByText('alerts.minutes_before(15), alerts.by_email(alerts.hours_before(1))')).toBeTruthy();
});
it("shows the calendar's defaults for an event that uses them", () => {
renderPopover({ useDefaultAlerts: true });
expect(screen.getByText('alerts.minutes_before(30)')).toBeTruthy();
});
});
+14 -1
View File
@@ -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<AlertRow, "id">, 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<string, CalendarEventAlert> | null }) {
const t = useTranslations("calendar");
+2 -16
View File
@@ -15,6 +15,7 @@ import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/ty
import { parseDuration, getEventColor } from "./event-card";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { buildRecurrenceSummary } from "./recurrence-editor";
import { formatEventAlertsLabel } from "./alert-list-editor";
import {
getUserParticipantId,
getUserStatus,
@@ -94,21 +95,6 @@ function formatDurationDisplay(minutes: number): string {
return `${h}h${m}min`;
}
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null;
const first = Object.values(event.alerts)[0];
if (!first || !first.trigger || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/);
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
const hourMatch = offset.match(/-?PT?(\d+)H$/);
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
return null;
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
if (!event.recurrenceRules?.length) return null;
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
@@ -190,7 +176,7 @@ export function EventDetailPopover({
[event, resolveContactName]
);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]);
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
const alertLabel = useMemo(() => formatEventAlertsLabel(event, calendar ? [calendar] : [], t), [event, calendar, t]);
// Gate affordances on calendar rights, not identity (see calendar-editability).
const editabilityCtx = useMemo<EditabilityContext>(() => {
+3 -23
View File
@@ -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<typeof useTranslations>): 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<typeof useTranslations>, 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);