feat(calendar): add event notifications with alert evaluation

Add client-side calendar event notification system that evaluates JMAP
CalendarEventAlert triggers and displays toast notifications when alert
times are reached. Includes configurable notification sound, acknowledged
alert persistence, and proactive event fetching for background alerts.
Also mounts ToastContainer globally to fix silent toast failures.
This commit is contained in:
Matthieu MALVACHE
2026-02-16 23:49:35 +01:00
committed by Matthieu MALVACHE
parent 73612313c0
commit fea21d5bcd
24 changed files with 949 additions and 47 deletions
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useCalendarNotificationStore } from '../calendar-notification-store';
const RETENTION_THRESHOLD_MS = 24 * 60 * 60 * 1000;
function getStore() {
return useCalendarNotificationStore.getState();
}
describe('calendar-notification-store', () => {
beforeEach(() => {
getStore().clearAll();
});
it('starts with empty acknowledged alerts', () => {
expect(getStore().acknowledgedAlerts).toEqual({});
});
it('acknowledges an alert with key and fireTimeMs', () => {
getStore().acknowledgeAlert('evt-1:a1:1000', 1000);
expect(getStore().acknowledgedAlerts).toEqual({ 'evt-1:a1:1000': 1000 });
});
it('acknowledges multiple alerts', () => {
getStore().acknowledgeAlert('key1', 1000);
getStore().acknowledgeAlert('key2', 2000);
expect(Object.keys(getStore().acknowledgedAlerts)).toHaveLength(2);
});
it('isAcknowledged returns true for acknowledged keys', () => {
getStore().acknowledgeAlert('key1', 1000);
expect(getStore().isAcknowledged('key1')).toBe(true);
});
it('isAcknowledged returns false for unknown keys', () => {
expect(getStore().isAcknowledged('unknown')).toBe(false);
});
it('clearAll empties the map', () => {
getStore().acknowledgeAlert('key1', 1000);
getStore().acknowledgeAlert('key2', 2000);
getStore().clearAll();
expect(getStore().acknowledgedAlerts).toEqual({});
});
it('cleanupStaleAlerts removes entries older than 24 hours', () => {
const now = Date.now();
const old = now - RETENTION_THRESHOLD_MS - 1000;
const recent = now - 1000;
getStore().acknowledgeAlert('old', old);
getStore().acknowledgeAlert('recent', recent);
getStore().cleanupStaleAlerts();
expect(getStore().isAcknowledged('old')).toBe(false);
expect(getStore().isAcknowledged('recent')).toBe(true);
});
it('cleanupStaleAlerts keeps entries at exactly the threshold', () => {
const now = Date.now();
const atThreshold = now - RETENTION_THRESHOLD_MS + 100;
getStore().acknowledgeAlert('boundary', atThreshold);
getStore().cleanupStaleAlerts();
expect(getStore().isAcknowledged('boundary')).toBe(true);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const RETENTION_THRESHOLD_MS = 24 * 60 * 60 * 1000;
interface CalendarNotificationStore {
acknowledgedAlerts: Record<string, number>;
acknowledgeAlert: (key: string, fireTimeMs: number) => void;
isAcknowledged: (key: string) => boolean;
cleanupStaleAlerts: () => void;
clearAll: () => void;
}
export const useCalendarNotificationStore = create<CalendarNotificationStore>()(
persist(
(set, get) => ({
acknowledgedAlerts: {},
acknowledgeAlert: (key, fireTimeMs) => {
set((state) => ({
acknowledgedAlerts: { ...state.acknowledgedAlerts, [key]: fireTimeMs },
}));
},
isAcknowledged: (key) => {
return key in get().acknowledgedAlerts;
},
cleanupStaleAlerts: () => {
const now = Date.now();
const cleaned = Object.fromEntries(
Object.entries(get().acknowledgedAlerts)
.filter(([, fireTimeMs]) => now - fireTimeMs < RETENTION_THRESHOLD_MS)
);
set({ acknowledgedAlerts: cleaned });
},
clearAll: () => {
set({ acknowledgedAlerts: {} });
},
}),
{
name: 'calendar-notification-storage',
partialize: (state) => ({
acknowledgedAlerts: state.acknowledgedAlerts,
}),
}
)
);
+9 -4
View File
@@ -162,10 +162,15 @@ export const useCalendarStore = create<CalendarStore>()(
setSelectedEventId: (id) => set({ selectedEventId: id }),
clearState: () => set({
...initialState,
selectedDate: new Date(),
}),
clearState: () => {
set({
...initialState,
selectedDate: new Date(),
});
import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => {
useCalendarNotificationStore.getState().clearAll();
}).catch(() => {});
},
}),
{
name: 'calendar-storage',
+10
View File
@@ -37,6 +37,10 @@ interface SettingsState {
sessionTimeout: number; // minutes (0 = never)
trustedSenders: string[]; // Email addresses that can load external content
// Calendar Notifications
calendarNotificationsEnabled: boolean;
calendarNotificationSound: boolean;
// Advanced
debugMode: boolean;
@@ -82,6 +86,10 @@ const DEFAULT_SETTINGS = {
sessionTimeout: 0, // Never
trustedSenders: [] as string[],
// Calendar Notifications
calendarNotificationsEnabled: true,
calendarNotificationSound: true,
// Advanced
debugMode: false,
};
@@ -136,6 +144,8 @@ export const useSettingsStore = create<SettingsState>()(
sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode,
sessionTimeout: state.sessionTimeout,
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
calendarNotificationSound: state.calendarNotificationSound,
debugMode: state.debugMode,
};
return JSON.stringify(settings, null, 2);