feat: add JMAP Calendar integration with month/week/day/agenda views
Full calendar support via JMAP Calendars (RFC 8984): - Event create/edit/delete with recurrence rules and reminders - Multi-day event spanning, column-based overlap layout - Locale-aware date formatting, first day of week and time format settings - Real-time updates via push notifications - ARIA accessibility, input validation, color sanitization - Keyboard shortcuts, mobile touch targets, focus trap - ICU pluralization for all 8 supported languages
This commit is contained in:
@@ -5,6 +5,7 @@ import { useEmailStore } from './email-store';
|
||||
import { useIdentityStore } from './identity-store';
|
||||
import { useContactStore } from './contact-store';
|
||||
import { useVacationStore } from './vacation-store';
|
||||
import { useCalendarStore } from './calendar-store';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
|
||||
interface AuthState {
|
||||
@@ -72,6 +73,13 @@ export const useAuthStore = create<AuthState>()(
|
||||
vacationStore.setSupported(false);
|
||||
}
|
||||
|
||||
// Initialize calendar if supported
|
||||
if (client.supportsCalendars()) {
|
||||
const calendarStore = useCalendarStore.getState();
|
||||
calendarStore.setSupported(true);
|
||||
calendarStore.fetchCalendars(client).catch((err) => console.error('Failed to fetch calendars:', err));
|
||||
}
|
||||
|
||||
// Success - save state (but NOT the password)
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
@@ -152,6 +160,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
// Clear vacation store state
|
||||
useVacationStore.getState().clearState();
|
||||
|
||||
// Clear calendar store state
|
||||
useCalendarStore.getState().clearState();
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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>;
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
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(),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'calendar-storage',
|
||||
partialize: (state) => ({
|
||||
selectedCalendarIds: state.selectedCalendarIds,
|
||||
viewMode: state.viewMode,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
+12
-1
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
|
||||
import { JMAPClient } from "@/lib/jmap/client";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
|
||||
interface EmailStore {
|
||||
@@ -1010,7 +1011,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
await get().fetchMailboxes(client);
|
||||
}
|
||||
|
||||
// Could also handle Thread, EmailSubmission, Identity changes in the future
|
||||
// Handle Calendar/CalendarEvent state changes - refresh calendar data
|
||||
if (accountChanges.Calendar || accountChanges.CalendarEvent) {
|
||||
const calendarStore = useCalendarStore.getState();
|
||||
if (calendarStore.supportsCalendar) {
|
||||
calendarStore.fetchCalendars(client);
|
||||
const { dateRange, selectedCalendarIds } = calendarStore;
|
||||
if (dateRange && selectedCalendarIds.length > 0) {
|
||||
calendarStore.fetchEvents(client, dateRange.start, dateRange.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle state change:', error);
|
||||
set({
|
||||
|
||||
Reference in New Issue
Block a user