feat: tell plugins when an event reminder fires and when it is dismissed

This commit is contained in:
Linus Rath
2026-10-14 03:40:19 +02:00
parent 8e2f9d7c94
commit 89452871fb
3 changed files with 123 additions and 3 deletions
+17 -3
View File
@@ -19,6 +19,11 @@ export interface Toast {
message?: string;
duration?: number;
onClick?: () => void;
/**
* Called when the user closes the toast: the close button, a click on the
* toast or on one of its actions. Not called when it times out.
*/
onDismiss?: () => void;
icon?: React.ReactNode;
action?: ToastAction;
secondaryAction?: ToastAction;
@@ -90,6 +95,15 @@ export function ToastItem({ toast, onClose }: ToastProps) {
setTimeout(() => onClose(toast.id), 280);
}, [onClose, toast.id]);
const dismissByUser = useCallback(() => {
try {
toast.onDismiss?.();
} catch {
// A failing listener must not keep the toast on screen.
}
dismiss();
}, [toast, dismiss]);
useEffect(() => {
if (!toast.duration || toast.duration <= 0 || isProgress) return;
@@ -112,7 +126,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
e.stopPropagation();
try {
action.onClick();
dismiss();
dismissByUser();
} catch {
// Don't close toast on error so user can retry
}
@@ -141,7 +155,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
onClick={() => {
if (toast.onClick && !toast.action) {
toast.onClick();
dismiss();
dismissByUser();
}
}}
>
@@ -180,7 +194,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
<button
onClick={(e) => {
e.stopPropagation();
dismiss();
dismissByUser();
}}
className={cn(
"flex-shrink-0 p-1 rounded-md transition-all",
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { useCalendarAlerts } from '../use-calendar-alerts';
import { ToastContainer } from '@/components/ui/toast';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useToastStore } from '@/stores/toast-store';
import { calendarHooks, clearAllHooks } from '@/lib/plugin-hooks';
import type { CalendarEvent } from '@/lib/jmap/types';
// A reminder that fires tells plugins (onCalendarAlert); the user closing
// its toast tells them it was dismissed (onCalendarAlertAcknowledge). A
// toast that just times out is not a dismissal.
const fireTime = new Date(Date.now() - 60_000).toISOString();
const event = {
id: 'e1',
uid: 'uid-1',
title: 'Dentist',
start: new Date(Date.now() + 10 * 60_000).toISOString().slice(0, 19),
utcStart: new Date(Date.now() + 10 * 60_000).toISOString(),
duration: 'PT1H',
calendarIds: { c1: true },
useDefaultAlerts: false,
alerts: {
a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: fireTime } },
},
} as unknown as CalendarEvent;
function Toasts() {
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
return <ToastContainer toasts={toasts} onClose={removeToast} />;
}
beforeEach(() => {
clearAllHooks();
useToastStore.setState({ toasts: [] });
useCalendarNotificationStore.getState().clearAll();
useAuthStore.setState({ isAuthenticated: true, client: null });
useSettingsStore.setState({ calendarNotificationsEnabled: true, calendarNotificationSound: false, enableCalendarTasks: false });
useCalendarStore.setState({ events: [event], calendars: [] });
});
afterEach(() => {
vi.useRealTimers();
});
describe('reminder hooks', () => {
it('reports a fired reminder once, and its dismissal when the user closes it', async () => {
const fired = vi.fn();
const acknowledged = vi.fn();
calendarHooks.onCalendarAlert.register('t', fired);
calendarHooks.onCalendarAlertAcknowledge.register('t', acknowledged);
renderHook(() => useCalendarAlerts());
render(<Toasts />);
await waitFor(() => expect(fired).toHaveBeenCalledTimes(1));
expect(fired).toHaveBeenCalledWith({
id: 'a1', eventId: 'e1', eventTitle: 'Dentist', triggerTime: new Date(fireTime).toISOString(),
});
expect(acknowledged).not.toHaveBeenCalled();
const close = screen.getByText('Dentist').closest('.toast-item')!.querySelector('button:last-of-type')!;
fireEvent.click(close);
expect(acknowledged).toHaveBeenCalledTimes(1);
expect(acknowledged).toHaveBeenCalledWith({ alertId: 'a1', eventId: 'e1' });
// Re-checking does not fire the same reminder again.
act(() => { useCalendarStore.setState({ events: [{ ...event }] }); });
expect(fired).toHaveBeenCalledTimes(1);
});
});
describe('toast onDismiss', () => {
it('runs when the user closes the toast, not when it times out', () => {
vi.useFakeTimers();
const onDismiss = vi.fn();
render(<Toasts />);
act(() => { useToastStore.getState().addToast({ type: 'info', title: 'Times out', duration: 1000, onDismiss }); });
act(() => { vi.advanceTimersByTime(2000); });
expect(screen.queryByText('Times out')).toBeNull();
expect(onDismiss).not.toHaveBeenCalled();
act(() => { useToastStore.getState().addToast({ type: 'info', title: 'Closed', duration: 10_000, onDismiss }); });
const close = screen.getByText('Closed').closest('.toast-item')!.querySelector('button:last-of-type')!;
fireEvent.click(close);
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
+11
View File
@@ -13,6 +13,7 @@ import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/cal
import { playNotificationSound } from '@/lib/notification-sound';
import { getPathPrefix } from '@/lib/browser-navigation';
import type { CalendarEvent } from '@/lib/jmap/types';
import { calendarHooks } from '@/lib/plugin-hooks';
const CHECK_INTERVAL_MS = 60 * 1000;
const PROACTIVE_FETCH_HOURS = 24;
@@ -55,6 +56,13 @@ export function useCalendarAlerts() {
const diffMs = (alert.event.utcStart ? new Date(alert.event.utcStart).getTime() : parseISO(alert.event.start).getTime()) - now;
const diffMin = Math.round(diffMs / 60000);
void calendarHooks.onCalendarAlert.emit({
id: alert.alertId,
eventId: alert.eventId,
eventTitle: alert.event.title || '',
triggerTime: new Date(alert.fireTimeMs).toISOString(),
});
const timeLabel = diffMin <= 0
? t('alert_now')
: t('alert_in_minutes', { count: diffMin });
@@ -71,6 +79,9 @@ export function useCalendarAlerts() {
onClick: () => {
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
},
onDismiss: () => {
void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
},
});
}