mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventModal } from '../event-modal';
|
||||
import type { Calendar, CalendarEvent, CalendarEventAlert } from '@/lib/jmap/types';
|
||||
|
||||
// #1209: the reminder rows edit the action (notification or email), and
|
||||
// alerts the rows can't show are saved back unchanged.
|
||||
|
||||
// Keep the values in the label so "15 minutes" and "1 hour" differ.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<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 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> = {}): 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(<EventModal event={event} startInEditMode calendars={calendars} onSave={onSave} onClose={vi.fn()} />);
|
||||
return onSave;
|
||||
}
|
||||
|
||||
async function savedData(onSave: ReturnType<typeof vi.fn>): Promise<Partial<CalendarEvent>> {
|
||||
fireEvent.click(screen.getByText('form.save'));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
return onSave.mock.calls[0][0];
|
||||
}
|
||||
|
||||
describe('EventModal reminder actions', () => {
|
||||
it('saves a row switched to email with action email', async () => {
|
||||
const onSave = renderEditor(makeEvent());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('alerts.action'), { target: { value: 'email' } });
|
||||
const data = await savedData(onSave);
|
||||
|
||||
const offsets = Object.values(data.alerts!).filter((a) => a.trigger['@type'] === 'OffsetTrigger');
|
||||
expect(offsets).toHaveLength(1);
|
||||
expect(offsets[0]).toMatchObject({ action: 'email', trigger: { offset: '-PT15M', relativeTo: 'start' } });
|
||||
});
|
||||
|
||||
it('opens an email alert as an email row', () => {
|
||||
renderEditor(makeEvent({
|
||||
alerts: {
|
||||
e: { ...absolute, trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' } },
|
||||
},
|
||||
}));
|
||||
expect((screen.getByLabelText('alerts.action') as HTMLSelectElement).value).toBe('email');
|
||||
expect((screen.getByLabelText('alerts.unit') as HTMLSelectElement).value).toBe('hours');
|
||||
});
|
||||
|
||||
it('keeps an absolute alert through a save', async () => {
|
||||
const onSave = renderEditor(makeEvent());
|
||||
|
||||
fireEvent.click(screen.getByLabelText('alerts.remove'));
|
||||
const data = await savedData(onSave);
|
||||
|
||||
expect(data.alerts).toEqual({ abs: absolute });
|
||||
});
|
||||
|
||||
it('keeps an alert after the start instead of turning it into one before', async () => {
|
||||
const after: CalendarEventAlert = { ...absolute, action: 'display', trigger: { '@type': 'OffsetTrigger', offset: 'PT10M', relativeTo: 'start' } };
|
||||
const onSave = renderEditor(makeEvent({ alerts: { after } }));
|
||||
|
||||
expect(screen.queryByLabelText('alerts.unit')).toBeNull();
|
||||
const data = await savedData(onSave);
|
||||
|
||||
expect(data.alerts).toEqual({ after });
|
||||
});
|
||||
});
|
||||
|
||||
// Calendar default alerts: a new event uses them; Stalwart copies them into
|
||||
// the event on create but not when the flag is set on an update.
|
||||
describe('EventModal calendar default alerts', () => {
|
||||
const offset = (o: string, action: CalendarEventAlert['action'] = 'display'): CalendarEventAlert => ({
|
||||
'@type': 'Alert',
|
||||
trigger: { '@type': 'OffsetTrigger', offset: o, relativeTo: 'start' },
|
||||
action,
|
||||
acknowledged: null,
|
||||
relatedTo: null,
|
||||
});
|
||||
const work = {
|
||||
id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true },
|
||||
defaultAlertsWithTime: { w1: offset('-PT15M') },
|
||||
defaultAlertsWithoutTime: { w2: offset('-P1D', 'email') },
|
||||
};
|
||||
const home = {
|
||||
id: 'cal-2', name: 'Home', isDefault: false, myRights: { mayWriteAll: true, mayWriteOwn: true },
|
||||
defaultAlertsWithTime: { h1: offset('-PT1H', 'email') },
|
||||
defaultAlertsWithoutTime: null,
|
||||
};
|
||||
const withDefaults = [work, home] as unknown as Calendar[];
|
||||
|
||||
function renderNew() {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<EventModal calendars={withDefaults} defaultDate={new Date('2026-11-20T10:00:00')} defaultCalendarId="cal-1" onSave={onSave} onClose={vi.fn()} />,
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Sync' } });
|
||||
return onSave;
|
||||
}
|
||||
|
||||
function renderExisting(event: CalendarEvent, startInEditMode = true) {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<EventModal event={event} startInEditMode={startInEditMode} calendars={withDefaults} onSave={onSave} onClose={vi.fn()} />);
|
||||
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<string, CalendarEventAlert>;
|
||||
const onSave = renderExisting(makeEvent({ useDefaultAlerts: true, alerts: stored }));
|
||||
|
||||
expect(screen.getByText('alerts.calendar_defaults')).toBeTruthy();
|
||||
expect(screen.getByText('alerts.minutes_before(30)')).toBeTruthy();
|
||||
|
||||
const data = await savedData(onSave);
|
||||
expect(data.useDefaultAlerts).toBe(true);
|
||||
expect(data.alerts).toEqual(stored);
|
||||
});
|
||||
|
||||
it('sends the calendar defaults when a custom event goes back to them', async () => {
|
||||
const onSave = renderExisting(makeEvent());
|
||||
|
||||
fireEvent.click(screen.getByText('alerts.use_defaults'));
|
||||
expect(screen.getByText('alerts.minutes_before(15)')).toBeTruthy();
|
||||
|
||||
const data = await savedData(onSave);
|
||||
expect(data.useDefaultAlerts).toBe(true);
|
||||
expect(data.alerts).toEqual({ w1: offset('-PT15M') });
|
||||
});
|
||||
|
||||
it('takes the current calendar defaults after Customize and Use calendar defaults', async () => {
|
||||
const onSave = renderExisting(makeEvent({ useDefaultAlerts: true, alerts: { k1: offset('-PT30M') } }));
|
||||
|
||||
fireEvent.click(screen.getByText('alerts.customize'));
|
||||
fireEvent.click(screen.getByText('alerts.use_defaults'));
|
||||
expect(screen.getByText('alerts.minutes_before(15)')).toBeTruthy();
|
||||
|
||||
const data = await savedData(onSave);
|
||||
expect(data.useDefaultAlerts).toBe(true);
|
||||
expect(data.alerts).toEqual({ w1: offset('-PT15M') });
|
||||
});
|
||||
|
||||
it('sends the new calendar defaults when a defaults event moves to another calendar', async () => {
|
||||
const onSave = renderExisting(makeEvent({ useDefaultAlerts: true, alerts: { k1: offset('-PT15M') } }));
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Work'), { target: { value: 'cal-2' } });
|
||||
const data = await savedData(onSave);
|
||||
|
||||
expect(data.useDefaultAlerts).toBe(true);
|
||||
expect(data.alerts).toEqual({ h1: offset('-PT1H', 'email') });
|
||||
});
|
||||
|
||||
it('labels the effective alerts of a defaults event in view mode', () => {
|
||||
renderExisting(makeEvent({ useDefaultAlerts: true, alerts: null }), false);
|
||||
expect(screen.getByText('alerts.minutes_before(15)')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Trash2, Plus } from "@/components/icons";
|
||||
import type { Calendar, CalendarEvent, CalendarEventAlert } from "@/lib/jmap/types";
|
||||
import {
|
||||
describeAlert,
|
||||
getEffectiveAlerts,
|
||||
newAlertRow,
|
||||
type AlertAction,
|
||||
type AlertRow,
|
||||
type AlertUnit,
|
||||
} from "@/lib/calendar-alerts";
|
||||
|
||||
type CalendarT = ReturnType<typeof useTranslations>;
|
||||
|
||||
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<AlertRow, "id">, t: CalendarT): string {
|
||||
let label: string;
|
||||
switch (row.unit) {
|
||||
case "at_time": label = t("alerts.at_time"); break;
|
||||
case "minutes": label = t("alerts.minutes_before", { count: row.value }); break;
|
||||
case "hours": label = t("alerts.hours_before", { count: row.value }); break;
|
||||
case "days": label = t("alerts.days_before", { count: row.value }); break;
|
||||
case "weeks": label = t("alerts.weeks_before", { count: row.value }); break;
|
||||
}
|
||||
return row.action === "email" ? t("alerts.by_email", { label }) : label;
|
||||
}
|
||||
|
||||
/** One line for an event's effective alerts, calendar defaults included. */
|
||||
export function formatEventAlertsLabel(event: CalendarEvent, calendars: Calendar[], t: CalendarT): string | null {
|
||||
const alerts = getEffectiveAlerts(event, calendars);
|
||||
if (!alerts) return null;
|
||||
const labels: string[] = [];
|
||||
for (const alert of Object.values(alerts)) {
|
||||
const row = describeAlert(alert);
|
||||
if (row) labels.push(formatAlertRowLabel(row, t));
|
||||
}
|
||||
return labels.length > 0 ? labels.join(", ") : null;
|
||||
}
|
||||
|
||||
/** Read-only list of an alert map, for alerts that come from calendar defaults. */
|
||||
export function AlertSummary({ alerts }: { alerts: Record<string, CalendarEventAlert> | 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 <p className="text-sm text-muted-foreground">{t("alerts.none")}</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="text-sm space-y-0.5">
|
||||
{labels.map((label, i) => <li key={i}>{label}</li>)}
|
||||
{other > 0 && <li className="text-muted-foreground">{t("alerts.other", { count: other })}</li>}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertListEditorProps {
|
||||
rows: AlertRow[];
|
||||
onChange: (rows: AlertRow[]) => void;
|
||||
/** Alerts the rows can't show (absolute, after the start); saved unchanged. */
|
||||
keptCount?: number;
|
||||
disabled?: boolean;
|
||||
/** Shown next to "Add reminder". */
|
||||
extraAction?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits reminders as rows of amount, unit and action (notification or email).
|
||||
* Each row is an offset before the start; alerts that don't fit stay with the
|
||||
* caller and are only counted here.
|
||||
*/
|
||||
export function AlertListEditor({ rows, onChange, keptCount = 0, disabled = false, extraAction }: AlertListEditorProps) {
|
||||
const t = useTranslations("calendar");
|
||||
|
||||
const update = (id: string, patch: Partial<Omit<AlertRow, "id">>) => {
|
||||
onChange(rows.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{rows.length === 0 && keptCount === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("alerts.none")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="flex items-center gap-2">
|
||||
{row.unit !== "at_time" && (
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={row.value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
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")}
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
value={row.unit}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const unit = e.target.value as AlertUnit;
|
||||
update(row.id, { unit, value: unit === "at_time" ? 0 : (row.value || 1) });
|
||||
}}
|
||||
className={`flex-1 min-w-0 ${selectClass}`}
|
||||
aria-label={t("alerts.unit")}
|
||||
>
|
||||
<option value="at_time">{t("alerts.at_time")}</option>
|
||||
<option value="minutes">{t("alerts.unit_minutes_before")}</option>
|
||||
<option value="hours">{t("alerts.unit_hours_before")}</option>
|
||||
<option value="days">{t("alerts.unit_days_before")}</option>
|
||||
<option value="weeks">{t("alerts.unit_weeks_before")}</option>
|
||||
</select>
|
||||
<select
|
||||
value={row.action}
|
||||
disabled={disabled}
|
||||
onChange={(e) => update(row.id, { action: e.target.value as AlertAction })}
|
||||
className={selectClass}
|
||||
aria-label={t("alerts.action")}
|
||||
>
|
||||
<option value="display">{t("alerts.action_display")}</option>
|
||||
<option value="email">{t("alerts.action_email")}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(rows.filter((r) => r.id !== row.id))}
|
||||
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
||||
aria-label={t("alerts.remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{keptCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t("alerts.kept", { count: keptCount })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange([...rows, newAlertRow(10, "minutes")])}
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("alerts.add")}
|
||||
</button>
|
||||
{extraAction}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>(() => {
|
||||
|
||||
@@ -6,9 +6,11 @@ import { toast } from "@/stores/toast-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LinkifiedText } from "@/components/ui/linkified-text";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "@/components/icons";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft } from "@/components/icons";
|
||||
import { format, parseISO, addHours, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
import { AlertListEditor, AlertSummary, formatEventAlertsLabel } from "./alert-list-editor";
|
||||
import { alertsToRows, getCalendarDefaultAlerts, rowsToAlerts, type AlertRow } from "@/lib/calendar-alerts";
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
@@ -109,65 +111,6 @@ function buildDuration(startDate: Date, endDate: Date): string {
|
||||
|
||||
type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom";
|
||||
|
||||
type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks";
|
||||
|
||||
interface AlertRow {
|
||||
id: string;
|
||||
value: number;
|
||||
unit: AlertUnit;
|
||||
}
|
||||
|
||||
let alertRowSeq = 0;
|
||||
function newAlertRow(value: number, unit: AlertUnit): AlertRow {
|
||||
alertRowSeq += 1;
|
||||
return { id: `r${alertRowSeq}`, value, unit };
|
||||
}
|
||||
|
||||
function alertRowToOffset(row: AlertRow): string | null {
|
||||
if (row.unit === "at_time") return "PT0S";
|
||||
const v = Math.max(0, Math.floor(row.value));
|
||||
if (!Number.isFinite(v) || v <= 0) return null;
|
||||
switch (row.unit) {
|
||||
case "minutes": return `-PT${v}M`;
|
||||
case "hours": return `-PT${v}H`;
|
||||
case "days": return `-P${v}D`;
|
||||
case "weeks": return `-P${v}W`;
|
||||
}
|
||||
}
|
||||
|
||||
function offsetToAlertRow(offset: string): AlertRow | null {
|
||||
if (offset === "PT0S" || offset === "P0D" || offset === "PT0M") {
|
||||
return newAlertRow(0, "at_time");
|
||||
}
|
||||
let m = offset.match(/^-?P(\d+)W$/);
|
||||
if (m) return newAlertRow(parseInt(m[1], 10), "weeks");
|
||||
m = offset.match(/^-?P(\d+)D$/);
|
||||
if (m) return newAlertRow(parseInt(m[1], 10), "days");
|
||||
m = offset.match(/^-?PT(\d+)H$/);
|
||||
if (m) return newAlertRow(parseInt(m[1], 10), "hours");
|
||||
m = offset.match(/^-?PT(\d+)M$/);
|
||||
if (m) {
|
||||
const mins = parseInt(m[1], 10);
|
||||
if (mins > 0 && mins % 1440 === 0) return newAlertRow(mins / 1440, "days");
|
||||
if (mins > 0 && mins % 60 === 0) return newAlertRow(mins / 60, "hours");
|
||||
return newAlertRow(mins, "minutes");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatAlertRowLabel(
|
||||
row: { value: number; unit: AlertUnit },
|
||||
t: ReturnType<typeof useTranslations>
|
||||
): string {
|
||||
if (row.unit === "at_time") return t("alerts.at_time");
|
||||
switch (row.unit) {
|
||||
case "minutes": return t("alerts.minutes_before", { count: row.value });
|
||||
case "hours": return t("alerts.hours_before", { count: row.value });
|
||||
case "days": return t("alerts.days_before", { count: row.value });
|
||||
case "weeks": return t("alerts.weeks_before", { count: row.value });
|
||||
}
|
||||
}
|
||||
|
||||
function formatDurationDisplay(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
@@ -176,19 +119,6 @@ function formatDurationDisplay(minutes: number): string {
|
||||
return `${h}h${m}min`;
|
||||
}
|
||||
|
||||
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||
if (!event.alerts) return null;
|
||||
const labels: string[] = [];
|
||||
for (const alert of Object.values(event.alerts)) {
|
||||
if (alert.trigger["@type"] !== "OffsetTrigger") continue;
|
||||
const row = offsetToAlertRow(alert.trigger.offset);
|
||||
if (!row) continue;
|
||||
labels.push(formatAlertRowLabel(row, t));
|
||||
}
|
||||
if (labels.length === 0) return null;
|
||||
return labels.join(", ");
|
||||
}
|
||||
|
||||
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
|
||||
if (!event.recurrenceRules?.length) return null;
|
||||
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
||||
@@ -384,36 +314,43 @@ export function EventModal({
|
||||
() => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null),
|
||||
[customRule, t, locale]
|
||||
);
|
||||
const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
|
||||
const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
|
||||
if (!event?.alerts) return [];
|
||||
const rows: AlertRow[] = [];
|
||||
for (const [id, alert] of Object.entries(event.alerts)) {
|
||||
// Preserve alerts we can't represent in this UI (absolute triggers,
|
||||
// email actions, offsets with non-canonical shapes) so they survive a save.
|
||||
if (alert.trigger["@type"] !== "OffsetTrigger" || alert.action !== "display") {
|
||||
preservedAlertsRef.current[id] = alert;
|
||||
continue;
|
||||
}
|
||||
const row = offsetToAlertRow(alert.trigger.offset);
|
||||
if (!row) {
|
||||
preservedAlertsRef.current[id] = alert;
|
||||
continue;
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
// A new event starts on the calendar's default alerts.
|
||||
const [useDefaultAlerts, setUseDefaultAlerts] = useState(() => (event ? event.useDefaultAlerts === true : true));
|
||||
// Alerts the rows can't represent (absolute triggers, offsets after the
|
||||
// start or relative to the end) are kept here so they survive a save.
|
||||
const [initialAlerts] = useState(() =>
|
||||
event && !event.useDefaultAlerts ? alertsToRows(event.alerts) : { rows: [], preserved: {} }
|
||||
);
|
||||
const [preservedAlerts, setPreservedAlerts] = useState<Record<string, CalendarEventAlert>>(initialAlerts.preserved);
|
||||
const [alertRows, setAlertRows] = useState<AlertRow[]>(initialAlerts.rows);
|
||||
|
||||
const addAlertRow = useCallback(() => {
|
||||
setAlertRows((prev) => [...prev, newAlertRow(10, "minutes")]);
|
||||
}, []);
|
||||
const updateAlertRow = useCallback((id: string, patch: Partial<Omit<AlertRow, "id">>) => {
|
||||
setAlertRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
}, []);
|
||||
const removeAlertRow = useCallback((id: string) => {
|
||||
setAlertRows((prev) => prev.filter((r) => r.id !== id));
|
||||
}, []);
|
||||
// Set once "Use calendar defaults" is clicked: from then on the event takes
|
||||
// the calendar's current defaults, not the copy it stored.
|
||||
const [refreshDefaults, setRefreshDefaults] = useState(false);
|
||||
|
||||
// What defaults mode stands for. Stalwart copies the calendar's defaults
|
||||
// into an event only when it is created, so an existing event that stays
|
||||
// in its calendar and keeps its all-day state shows the copy it stored;
|
||||
// otherwise the selected calendar's current defaults apply.
|
||||
const defaultAlerts = useMemo(() => {
|
||||
if (
|
||||
!refreshDefaults &&
|
||||
event?.useDefaultAlerts &&
|
||||
event.alerts && Object.keys(event.alerts).length > 0 &&
|
||||
getPrimaryCalendarId(event) === calendarId &&
|
||||
!!event.showWithoutTime === allDay
|
||||
) {
|
||||
return event.alerts;
|
||||
}
|
||||
return getCalendarDefaultAlerts(calendars.find((c) => c.id === calendarId), allDay);
|
||||
}, [refreshDefaults, event, calendars, calendarId, allDay]);
|
||||
|
||||
const customizeAlerts = useCallback(() => {
|
||||
const { rows, preserved } = alertsToRows(defaultAlerts);
|
||||
setAlertRows(rows);
|
||||
setPreservedAlerts(preserved);
|
||||
setUseDefaultAlerts(false);
|
||||
}, [defaultAlerts]);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
// Set synchronously, so a second save in the same tick is refused too.
|
||||
@@ -612,25 +549,20 @@ export function EventModal({
|
||||
if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null;
|
||||
}
|
||||
|
||||
const builtAlerts: Record<string, CalendarEventAlert> = { ...preservedAlertsRef.current };
|
||||
let alertIdx = 0;
|
||||
for (const row of alertRows) {
|
||||
const offset = alertRowToOffset(row);
|
||||
if (offset === null) continue;
|
||||
let key = `alert${++alertIdx}`;
|
||||
while (key in builtAlerts) key = `alert${++alertIdx}`;
|
||||
builtAlerts[key] = {
|
||||
"@type": "Alert",
|
||||
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
|
||||
action: "display",
|
||||
acknowledged: null,
|
||||
relatedTo: null,
|
||||
};
|
||||
}
|
||||
if (Object.keys(builtAlerts).length > 0) {
|
||||
data.alerts = builtAlerts;
|
||||
} else if (event && event.alerts && Object.keys(event.alerts).length > 0) {
|
||||
data.alerts = null;
|
||||
if (useDefaultAlerts) {
|
||||
data.useDefaultAlerts = true;
|
||||
// Stalwart copies the calendar's defaults into a new event itself
|
||||
// (sending alerts too would double them), but setting the flag on an
|
||||
// update keeps whatever alerts the event had, so updates carry them.
|
||||
if (event) data.alerts = defaultAlerts ? structuredClone(defaultAlerts) : null;
|
||||
} else {
|
||||
data.useDefaultAlerts = false;
|
||||
const builtAlerts = rowsToAlerts(alertRows, preservedAlerts);
|
||||
if (Object.keys(builtAlerts).length > 0) {
|
||||
data.alerts = builtAlerts;
|
||||
} else if (event && event.alerts && Object.keys(event.alerts).length > 0) {
|
||||
data.alerts = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
|
||||
@@ -707,7 +639,7 @@ export function EventModal({
|
||||
savingRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving, t]);
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving, t]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -882,7 +814,7 @@ export function EventModal({
|
||||
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
|
||||
const viewParticipants = getParticipantList(event, { resolveName: resolveContactName });
|
||||
const recurrenceLabel = getRecurrenceLabel(event, t, locale);
|
||||
const alertLabel = getAlertLabel(event, t);
|
||||
const alertLabel = formatEventAlertsLabel(event, calendars, t);
|
||||
const eventCalendar = calendars.find(c => event.calendarIds[c.id]);
|
||||
const color = getEventColor(event, eventCalendar);
|
||||
|
||||
@@ -1343,64 +1275,34 @@ export function EventModal({
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
|
||||
{alertRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("alerts.none")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{alertRows.map((row) => (
|
||||
<div key={row.id} className="flex items-center gap-2">
|
||||
{row.unit !== "at_time" && (
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={row.value}
|
||||
onChange={(e) => {
|
||||
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")}
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
value={row.unit}
|
||||
onChange={(e) => {
|
||||
const unit = e.target.value as AlertUnit;
|
||||
updateAlertRow(row.id, {
|
||||
unit,
|
||||
value: unit === "at_time" ? 0 : (row.value || 1),
|
||||
});
|
||||
}}
|
||||
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
aria-label={t("alerts.unit")}
|
||||
>
|
||||
<option value="at_time">{t("alerts.at_time")}</option>
|
||||
<option value="minutes">{t("alerts.unit_minutes_before")}</option>
|
||||
<option value="hours">{t("alerts.unit_hours_before")}</option>
|
||||
<option value="days">{t("alerts.unit_days_before")}</option>
|
||||
<option value="weeks">{t("alerts.unit_weeks_before")}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAlertRow(row.id)}
|
||||
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label={t("alerts.remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{useDefaultAlerts ? (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">{t("alerts.calendar_defaults")}</p>
|
||||
<AlertSummary alerts={defaultAlerts} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={customizeAlerts}
|
||||
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
{t("alerts.customize")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<AlertListEditor
|
||||
rows={alertRows}
|
||||
onChange={setAlertRows}
|
||||
keptCount={Object.keys(preservedAlerts).length}
|
||||
extraAction={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setUseDefaultAlerts(true); setRefreshDefaults(true); }}
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
{t("alerts.use_defaults")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAlertRow}
|
||||
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("alerts.add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{attendees.length > 0 && (
|
||||
|
||||
@@ -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>): 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<typeof vi.fn>;
|
||||
|
||||
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(<CalendarManagementSettings />);
|
||||
expect(screen.getAllByLabelText('default_alerts')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('saves both lists with their actions and keeps alerts it cannot show', async () => {
|
||||
render(<CalendarManagementSettings />);
|
||||
fireEvent.click(screen.getByLabelText('default_alerts'));
|
||||
|
||||
// Timed list first, then all-day.
|
||||
const [addTimed, addAllDay] = screen.getAllByText('alerts.add');
|
||||
fireEvent.click(addTimed);
|
||||
fireEvent.change(screen.getByLabelText('alerts.action'), { target: { value: 'email' } });
|
||||
fireEvent.click(addAllDay);
|
||||
fireEvent.change(screen.getAllByLabelText('alerts.unit')[1], { target: { value: 'days' } });
|
||||
fireEvent.change(screen.getAllByLabelText('alerts.amount')[1], { target: { value: '1' } });
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
|
||||
await waitFor(() => expect(updateCalendar).toHaveBeenCalledTimes(1));
|
||||
const [, calendarId, updates] = updateCalendar.mock.calls[0];
|
||||
expect(calendarId).toBe('cal-1');
|
||||
expect(Object.values(updates.defaultAlertsWithTime)).toEqual([
|
||||
expect.objectContaining({ action: 'email', trigger: { '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'start' } }),
|
||||
]);
|
||||
expect(updates.defaultAlertsWithoutTime.abs).toBe(absolute);
|
||||
expect(Object.values(updates.defaultAlertsWithoutTime)).toContainEqual(
|
||||
expect.objectContaining({ action: 'display', trigger: expect.objectContaining({ offset: '-P1D' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears a list by sending an empty map, which Stalwart accepts where it refuses null', async () => {
|
||||
useCalendarStore.setState({
|
||||
calendars: [makeCalendar({
|
||||
defaultAlertsWithTime: {
|
||||
a: { ...absolute, trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' } },
|
||||
},
|
||||
})],
|
||||
} as never);
|
||||
render(<CalendarManagementSettings />);
|
||||
fireEvent.click(screen.getByLabelText('default_alerts'));
|
||||
|
||||
fireEvent.click(screen.getByLabelText('alerts.remove'));
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
|
||||
await waitFor(() => expect(updateCalendar).toHaveBeenCalledTimes(1));
|
||||
expect(updateCalendar.mock.calls[0][2]).toEqual({ defaultAlertsWithTime: {}, defaultAlertsWithoutTime: {} });
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,14 @@ import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { CALENDAR_KIND_COMPONENTS, CalendarKindPicker, type CalendarKind } from '@/components/calendar/calendar-kind-picker';
|
||||
import type { CalendarComponentType } from '@/lib/jmap/types';
|
||||
import type { Calendar, CalendarComponentType } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from '@/components/icons';
|
||||
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell } from '@/components/icons';
|
||||
import { AlertListEditor } from '@/components/calendar/alert-list-editor';
|
||||
import { alertsToRows, rowsToAlerts, type AlertRow } from '@/lib/calendar-alerts';
|
||||
import { ShareCollectionDialog } from './share-collection-dialog';
|
||||
import type { CalendarRights } from '@/lib/jmap/types';
|
||||
import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils';
|
||||
@@ -150,6 +152,83 @@ export function CalendarEditForm({
|
||||
|
||||
export { CalendarColorPicker, CALENDAR_COLORS };
|
||||
|
||||
type DefaultAlertsUpdate = Pick<Calendar, 'defaultAlertsWithTime' | 'defaultAlertsWithoutTime'>;
|
||||
|
||||
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<AlertRow[]>(timed.rows);
|
||||
const [allDayRows, setAllDayRows] = useState<AlertRow[]>(allDay.rows);
|
||||
|
||||
// An emptied list is sent as {}: Stalwart refuses null for these maps.
|
||||
const submit = () => onSave({
|
||||
defaultAlertsWithTime: rowsToAlerts(timedRows, timed.preserved),
|
||||
defaultAlertsWithoutTime: rowsToAlerts(allDayRows, allDay.preserved),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">{t('default_alerts_title', { name: calendar.name })}</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('default_alerts_hint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t('default_alerts_timed')}
|
||||
</label>
|
||||
<AlertListEditor
|
||||
rows={timedRows}
|
||||
onChange={setTimedRows}
|
||||
keptCount={Object.keys(timed.preserved).length}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t('default_alerts_all_day')}
|
||||
</label>
|
||||
<AlertListEditor
|
||||
rows={allDayRows}
|
||||
onChange={setAllDayRows}
|
||||
keptCount={Object.keys(allDay.preserved).length}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{t('save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1.5 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarManagementSettings() {
|
||||
const t = useTranslations('calendar.management');
|
||||
const { client, serverUrl, username } = useAuthStore();
|
||||
@@ -168,6 +247,7 @@ export function CalendarManagementSettings() {
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [alertsEditingId, setAlertsEditingId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [clearingId, setClearingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -329,6 +409,20 @@ export function CalendarManagementSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDefaultAlertsSave = async (calendarId: string, updates: DefaultAlertsUpdate) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await updateCalendar(client, calendarId, updates);
|
||||
setAlertsEditingId(null);
|
||||
toast.success(t('default_alerts_updated'));
|
||||
} catch {
|
||||
toast.error(t('error_update'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorChange = async (calendarId: string, color: string) => {
|
||||
if (!client) return;
|
||||
// Shared calendars: recolor locally only - the viewer usually can't write
|
||||
@@ -424,6 +518,18 @@ export function CalendarManagementSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
if (alertsEditingId === cal.id) {
|
||||
return (
|
||||
<CalendarDefaultAlertsForm
|
||||
key={cal.id}
|
||||
calendar={cal}
|
||||
onSave={(updates) => handleDefaultAlertsSave(cal.id, updates)}
|
||||
onCancel={() => setAlertsEditingId(null)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (deletingId === cal.id) {
|
||||
return (
|
||||
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
|
||||
@@ -560,6 +666,18 @@ export function CalendarManagementSettings() {
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{/* The owner's defaults; a sharee can't write the calendar. */}
|
||||
{!cal.isShared && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAlertsEditingId(cal.id)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('default_alerts')}
|
||||
aria-label={t('default_alerts')}
|
||||
>
|
||||
<Bell className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
getEffectiveAlerts,
|
||||
buildAlertKey,
|
||||
getPendingAlerts,
|
||||
getCalendarDefaultAlerts,
|
||||
offsetToAlertUnit,
|
||||
describeAlert,
|
||||
alertsToRows,
|
||||
rowsToAlerts,
|
||||
newAlertRow,
|
||||
} from '../calendar-alerts';
|
||||
import type {
|
||||
CalendarEvent,
|
||||
@@ -224,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()];
|
||||
@@ -237,6 +250,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);
|
||||
|
||||
@@ -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, unknown>, 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<unknown> }, '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<string, Record<string, unknown>>).cal1;
|
||||
expect(update).toEqual({ defaultAlertsWithTime: withTime, defaultAlertsWithoutTime: null });
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
@@ -90,6 +96,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<string, CalendarEventAlert> | 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<AlertRow, 'value' | 'unit'>): 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<AlertRow, 'id'> | 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<string, CalendarEventAlert> | null | undefined): {
|
||||
rows: AlertRow[];
|
||||
preserved: Record<string, CalendarEventAlert>;
|
||||
} {
|
||||
const rows: AlertRow[] = [];
|
||||
const preserved: Record<string, CalendarEventAlert> = {};
|
||||
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<string, CalendarEventAlert> = {}
|
||||
): Record<string, CalendarEventAlert> {
|
||||
const built: Record<string, CalendarEventAlert> = { ...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}`;
|
||||
}
|
||||
|
||||
+17
-2
@@ -3249,7 +3249,16 @@
|
||||
"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}}",
|
||||
"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",
|
||||
@@ -3450,7 +3459,13 @@
|
||||
"kind_events": "Events",
|
||||
"kind_tasks": "Tasks",
|
||||
"kind_both": "Events and tasks",
|
||||
"kind_hint": "Decides which apps see this calendar when it is synced over CalDAV. It cannot be changed later."
|
||||
"kind_hint": "Decides which apps see this calendar when it is synced over CalDAV. It cannot be changed later.",
|
||||
"default_alerts": "Default reminders",
|
||||
"default_alerts_title": "Default reminders for \"{name}\"",
|
||||
"default_alerts_hint": "New events in this calendar get these reminders unless you change them in the event. Changing them here doesn't change existing events. Email reminders are sent by the mail server.",
|
||||
"default_alerts_timed": "Events with a time",
|
||||
"default_alerts_all_day": "All-day events",
|
||||
"default_alerts_updated": "Default reminders updated"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal Subscription",
|
||||
|
||||
Reference in New Issue
Block a user