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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user