Files
SRCmail/stores/calendar-store.ts
T
Matthieu MALVACHEandMatthieu MALVACHE fea21d5bcd 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.
2026-02-16 23:49:35 +01:00

184 lines
6.1 KiB
TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { JMAPClient } from '@/lib/jmap/client';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
import { debug } from '@/lib/debug';
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda';
interface CalendarStore {
calendars: Calendar[];
events: CalendarEvent[];
selectedDate: Date;
viewMode: CalendarViewMode;
selectedCalendarIds: string[];
selectedEventId: string | null;
isLoading: boolean;
isLoadingEvents: boolean;
supportsCalendar: boolean;
error: string | null;
dateRange: { start: string; end: string } | null;
setSupported: (supported: boolean) => void;
fetchCalendars: (client: JMAPClient) => Promise<void>;
fetchEvents: (client: JMAPClient, start: string, end: string) => Promise<void>;
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>) => Promise<CalendarEvent | null>;
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>) => Promise<void>;
deleteEvent: (client: JMAPClient, id: string) => Promise<void>;
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
setSelectedDate: (date: Date) => void;
setViewMode: (mode: CalendarViewMode) => void;
toggleCalendarVisibility: (calendarId: string) => void;
setSelectedEventId: (id: string | null) => void;
clearState: () => void;
}
const initialState = {
calendars: [],
events: [],
selectedDate: new Date(),
selectedCalendarIds: [] as string[],
selectedEventId: null as string | null,
isLoading: false,
isLoadingEvents: false,
supportsCalendar: false,
error: null as string | null,
dateRange: null as { start: string; end: string } | null,
};
export const useCalendarStore = create<CalendarStore>()(
persist(
(set, get) => ({
...initialState,
viewMode: 'month' as CalendarViewMode,
setSupported: (supported) => set({ supportsCalendar: supported }),
fetchCalendars: async (client) => {
set({ isLoading: true, error: null });
try {
const calendars = await client.getCalendars();
const { selectedCalendarIds } = get();
set({
calendars,
isLoading: false,
selectedCalendarIds: selectedCalendarIds.length === 0
? calendars.map(c => c.id)
: selectedCalendarIds,
});
} catch (error) {
debug.error('Failed to fetch calendars:', error);
set({ error: 'Failed to load calendars', isLoading: false });
}
},
fetchEvents: async (client, start, end) => {
set({ isLoadingEvents: true, error: null });
try {
const { selectedCalendarIds } = get();
const events = await client.queryCalendarEvents({
after: start,
before: end,
inCalendars: selectedCalendarIds.length > 0 ? selectedCalendarIds : undefined,
});
set({ events, isLoadingEvents: false, dateRange: { start, end } });
} catch (error) {
debug.error('Failed to fetch events:', error);
set({ error: 'Failed to load events', isLoadingEvents: false });
}
},
createEvent: async (client, event) => {
set({ error: null });
try {
const created = await client.createCalendarEvent(event);
set((state) => ({ events: [...state.events, created] }));
return created;
} catch (error) {
debug.error('Failed to create event:', error);
set({ error: 'Failed to create event' });
return null;
}
},
updateEvent: async (client, id, updates) => {
set({ error: null });
try {
await client.updateCalendarEvent(id, updates);
set((state) => ({
events: state.events.map(e => e.id === id ? { ...e, ...updates } : e),
}));
} catch (error) {
debug.error('Failed to update event:', error);
set({ error: 'Failed to update event' });
throw error;
}
},
importEvents: async (client, events, calendarId) => {
let imported = 0;
for (const event of events) {
try {
const data: Partial<CalendarEvent> = {
...event,
calendarIds: { [calendarId]: true },
};
const created = await client.createCalendarEvent(data);
set((state) => ({ events: [...state.events, created] }));
imported++;
} catch (error) {
debug.error('Failed to import event:', event.title, error);
}
}
return imported;
},
deleteEvent: async (client, id) => {
set({ error: null });
try {
await client.deleteCalendarEvent(id);
set((state) => ({
events: state.events.filter(e => e.id !== id),
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
}));
} catch (error) {
debug.error('Failed to delete event:', error);
set({ error: 'Failed to delete event' });
throw error;
}
},
setSelectedDate: (date) => set({ selectedDate: date }),
setViewMode: (mode) => set({ viewMode: mode }),
toggleCalendarVisibility: (calendarId) => set((state) => {
const ids = state.selectedCalendarIds;
return {
selectedCalendarIds: ids.includes(calendarId)
? ids.filter(id => id !== calendarId)
: [...ids, calendarId],
};
}),
setSelectedEventId: (id) => set({ selectedEventId: id }),
clearState: () => {
set({
...initialState,
selectedDate: new Date(),
});
import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => {
useCalendarNotificationStore.getState().clearAll();
}).catch(() => {});
},
}),
{
name: 'calendar-storage',
partialize: (state) => ({
selectedCalendarIds: state.selectedCalendarIds,
viewMode: state.viewMode,
}),
}
)
);