import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; import { browserDefaultTimeZone } from "@/utils/timezone"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { CalendarEvent } from "../Events/EventsTypes"; import { addCalendarResourceAsync } from "./api/addCalendarResourceAsync"; import { Calendar } from "./CalendarTypes"; import { addSharedCalendarAsync, createCalendarAsync, deleteEventAsync, deleteEventInstanceAsync, getCalendarDetailAsync, getCalendarsListAsync, getEventAsync, getTempCalendarsListAsync, importEventFromFileAsync, moveEventAsync, patchACLCalendarAsync, patchCalendarAsync, putEventAsync, refreshCalendarWithSyncToken, removeCalendarAsync, updateEventInstanceAsync, updateSeriesAsync, } from "./services"; const CalendarSlice = createSlice({ name: "calendars", initialState: { list: {} as Record, templist: {} as Record, pending: true, error: null as string | null, } as { list: Record; templist: Record; pending: boolean; error: string | null; }, reducers: { createCalendar: ( state, action: PayloadAction>> ) => { const id = Date.now().toString(36); state.list[id] = {} as Calendar; state.list[id].name = action.payload.name as string; state.list[id].color = action.payload.color as Record; state.list[id].description = action.payload.description as string; state.list[id].events = {}; }, addEvent: ( state, action: PayloadAction<{ calendarUid: string; event: CalendarEvent }> ) => { if (!state.list[action.payload.calendarUid].events) { state.list[action.payload.calendarUid].events = {}; } state.list[action.payload.calendarUid].events[action.payload.event.uid] = action.payload.event; state.list[action.payload.calendarUid].events[ action.payload.event.uid ].URL = `/calendars/${action.payload.calendarUid}/${extractEventBaseUuid( action.payload.event.uid )}.ics`; }, removeEvent: ( state, action: PayloadAction<{ calendarUid: string; eventUid: string }> ) => { delete state.list[action.payload.calendarUid].events[ action.payload.eventUid ]; }, removeTempCal: (state, action: PayloadAction) => { delete state.templist[action.payload]; }, emptyEventsCal: ( state, action: PayloadAction<{ calId?: string; calType?: "temp" }> ) => { const cals = action.payload.calType === "temp" ? state.templist : state.list; if (action.payload.calId) { cals[action.payload.calId].events = {}; } else { Object.keys(state.templist).forEach( (calId) => (cals[calId].events = {}) ); } }, updateEventLocal: ( state, action: PayloadAction<{ calId: string; event: CalendarEvent }> ) => { const { calId, event } = action.payload; state.list[calId].events[event.uid] = event; }, clearFetchCache: (state, action: PayloadAction) => { if (!state.list[action.payload]) return; state.list[action.payload].lastCacheCleared = Date.now(); }, clearError: (state) => { state.error = null; }, updateCalColor: ( state, action: PayloadAction<{ id: string; color: { light: string; dark: string }; }> ) => { state.list[action.payload.id].color = action.payload.color; }, }, extraReducers: (builder) => { builder // Fulfilled cases .addCase( getCalendarsListAsync.fulfilled, ( state, action: PayloadAction<{ importedCalendars: Record; errors: string; }> ) => { state.pending = false; state.error = action.payload.errors.length ? action.payload.errors : null; Object.entries(action.payload.importedCalendars).forEach( ([id, cal]) => { state.list[id] = { ...cal, events: state.list[id]?.events || {}, }; } ); // Remove calendars that no longer exist Object.keys(state.list).forEach((id) => { if (!action.payload.importedCalendars[id]) { delete state.list[id]; } }); } ) .addCase( getTempCalendarsListAsync.fulfilled, (state, action: PayloadAction>) => { state.pending = false; Object.keys(action.payload).forEach( (id) => (state.templist[id] = action.payload[id]) ); } ) .addCase( getCalendarDetailAsync.fulfilled, ( state, action: PayloadAction<{ calId: string; events: CalendarEvent[]; calType?: string; syncToken?: string; }> ) => { state.pending = false; const type = action.payload.calType === "temp" ? "templist" : "list"; if (!state[type][action.payload.calId]) { return; } state[type][action.payload.calId].syncToken = action.payload.syncToken; action.payload.events.forEach((event) => { state[type][action.payload.calId].events[event.uid] = event; }); Object.keys(state[type][action.payload.calId].events).forEach( (id) => { state[type][action.payload.calId].events[id].color = state[type][action.payload.calId].color; state[type][action.payload.calId].events[id].calId = action.payload.calId; if (!state[type][action.payload.calId].events[id].timezone) { state[type][action.payload.calId].events[id].timezone = browserDefaultTimeZone; } } ); } ) .addCase(putEventAsync.fulfilled, (state) => { state.pending = false; state.error = null; }) .addCase( getEventAsync.fulfilled, ( state, action: PayloadAction<{ calId: string; event: CalendarEvent }> ) => { state.pending = false; if (!state.list[action.payload.calId]) { state.list[action.payload.calId] = { id: action.payload.calId, events: {}, } as Calendar; } if ( Object.keys(state.list[action.payload.calId].events).find( (eventId: string) => { const eventIdBase = extractEventBaseUuid(eventId); return eventIdBase === action.payload.event.uid; } ) ) { Object.keys(state.list[action.payload.calId].events) .filter((eventKey) => { const baseUid = extractEventBaseUuid(eventKey); return baseUid === action.payload.event.uid; }) .forEach((eventKey) => { state.list[action.payload.calId].events[eventKey] = { ...state.list[action.payload.calId].events[eventKey], repetition: action.payload.event.repetition, timezone: action.payload.event.timezone, }; }); } else { state.list[action.payload.calId].events[action.payload.event.uid] = action.payload.event; } } ) .addCase(moveEventAsync.fulfilled, (state) => { state.pending = false; state.error = null; }) .addCase(deleteEventAsync.fulfilled, (state) => { state.pending = false; state.error = null; }) .addCase(deleteEventInstanceAsync.fulfilled, (state, action) => { state.pending = false; delete state.list[action.payload.calId].events[action.payload.eventId]; state.error = null; }) .addCase(updateEventInstanceAsync.fulfilled, (state, action) => { state.pending = false; state.list[action.payload.calId].events[action.payload.event.uid] = action.payload.event; state.error = null; }) .addCase(updateSeriesAsync.fulfilled, (state) => { state.pending = false; state.error = null; }) .addCase(createCalendarAsync.fulfilled, (state, action) => { state.pending = false; state.list[`${action.payload.userId}/${action.payload.calId}`] = { color: action.payload.color, id: `${action.payload.userId}/${action.payload.calId}`, link: `/calendars/${action.payload.userId}/${action.payload.calId}.json`, description: action.payload.desc, name: action.payload.name, owner: action.payload.owner, events: {}, } as Calendar; state.error = null; }) .addCase(patchCalendarAsync.fulfilled, (state) => { state.pending = false; state.error = null; }) .addCase(addSharedCalendarAsync.fulfilled, (state, action) => { state.pending = false; state.list[action.payload.calId] = { color: action.payload.color, id: action.payload.calId, link: action.payload.link, description: action.payload.desc, name: action.payload.name, events: {}, owner: action.payload.owner, } as Calendar; state.error = null; }) .addCase(addCalendarResourceAsync.fulfilled, (state, action) => { state.pending = false; state.list[action.payload.calId] = { color: action.payload.color, id: action.payload.calId, link: action.payload.link, description: action.payload.desc, name: action.payload.name, events: {}, owner: action.payload.owner, } as Calendar; state.error = null; }) .addCase(removeCalendarAsync.fulfilled, (state) => { state.pending = false; state.error = null; }) .addCase(patchACLCalendarAsync.fulfilled, (state, action) => { state.pending = false; state.list[action.payload.calId].visibility = action.payload.request !== "" ? "public" : "private"; state.error = null; }) .addCase(importEventFromFileAsync.fulfilled, (state) => { state.pending = false; state.error = null; }) .addCase(refreshCalendarWithSyncToken.fulfilled, (state, action) => { state.pending = false; state.error = null; const { calId, deletedEvents, createdOrUpdatedEvents, calType, syncToken, syncStatus, } = action.payload; const target = calType === "temp" ? state.templist[calId] : state.list[calId]; if (!target) { return; } if (syncStatus === "SUCCESS") { const deletedSet = new Set(deletedEvents); // working with a Set for deletion avoids O(nxm) complexity Object.values(target.events) .filter((event) => { return deletedSet.has(event.URL); }) .forEach((event) => { delete target.events[event.uid]; }); for (const event of createdOrUpdatedEvents) { target.events[event.uid] = event; } target.syncToken = syncToken; } }) // Pending cases .addCase(getCalendarDetailAsync.pending, (state) => { state.pending = true; }) .addCase(getEventAsync.pending, (state) => { state.pending = true; }) .addCase(getCalendarsListAsync.pending, (state) => { state.pending = true; }) .addCase(putEventAsync.pending, (state) => { state.pending = true; }) .addCase(moveEventAsync.pending, (state) => { state.pending = true; }) .addCase(deleteEventAsync.pending, (state) => { state.pending = true; }) .addCase(deleteEventInstanceAsync.pending, (state) => { state.pending = true; }) .addCase(updateEventInstanceAsync.pending, (state) => { state.pending = true; }) .addCase(updateSeriesAsync.pending, (state) => { state.pending = true; }) .addCase(patchCalendarAsync.pending, (state) => { state.pending = true; }) .addCase(createCalendarAsync.pending, (state) => { state.pending = true; }) .addCase(getTempCalendarsListAsync.pending, (state) => { state.pending = true; }) .addCase(addSharedCalendarAsync.pending, (state) => { state.pending = true; }) .addCase(addCalendarResourceAsync.pending, (state) => { state.pending = true; }) .addCase(removeCalendarAsync.pending, (state) => { state.pending = true; }) .addCase(patchACLCalendarAsync.pending, (state) => { state.pending = true; }) .addCase(importEventFromFileAsync.pending, (state) => { state.pending = true; }) .addCase(refreshCalendarWithSyncToken.pending, (state) => { state.pending = true; }) // Rejected cases .addCase(getCalendarsListAsync.rejected, (state, action) => { state.pending = false; if (action.payload?.status !== 401) { state.error = action.payload?.message || action.error.message || "Failed to load calendars"; } }) .addCase(getTempCalendarsListAsync.rejected, (state, action) => { state.pending = false; if ( action.payload?.message.includes("aborted") || action.error.name === "AbortError" ) { return; } state.error = action.payload?.message || action.error.message || "Failed to load temporary calendars"; }) .addCase(getCalendarDetailAsync.rejected, (state, action) => { state.pending = false; if ( action.payload?.message.includes("aborted") || action.error.name === "AbortError" ) { return; } state.error = action.payload?.message || action.error.message || "Failed to load calendar details"; }) .addCase(putEventAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to create event"; }) .addCase(getEventAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to load event"; }) .addCase(moveEventAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to move event"; }) .addCase(deleteEventAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to delete event"; }) .addCase(deleteEventInstanceAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to delete event instance"; }) .addCase(updateEventInstanceAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to update event instance"; }) .addCase(updateSeriesAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to update event series"; }) .addCase(patchCalendarAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to update calendar"; }) .addCase(createCalendarAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to create calendar"; }) .addCase(addSharedCalendarAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to add shared calendar"; }) .addCase(addCalendarResourceAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to add calendar resource"; }) .addCase(removeCalendarAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to remove calendar"; }) .addCase(patchACLCalendarAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to update calendar permissions"; }) .addCase(importEventFromFileAsync.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to import event from file"; }) .addCase(refreshCalendarWithSyncToken.rejected, (state, action) => { state.pending = false; state.error = action.payload?.message || action.error.message || "Failed to refresh calendar"; }); }, }); export const { addEvent, removeEvent, createCalendar, updateEventLocal, removeTempCal, emptyEventsCal, clearFetchCache, clearError, updateCalColor, } = CalendarSlice.actions; export default CalendarSlice.reducer;