feat: implement client-side recurrence expansion for calendar events

This commit is contained in:
Linus Rath
2026-03-29 17:12:30 +02:00
parent 1e90885f3f
commit 75b5d31414
5 changed files with 406 additions and 161 deletions
-7
View File
@@ -1,7 +0,0 @@
# Check for AI attribution in commit message
if grep -qi "co-authored-by.*claude\|co-authored-by.*anthropic\|claude code\|claude sonnet\|claude opus" "$1"; then
echo "❌ ERROR: Commit message contains AI attribution (Claude/Anthropic)"
echo " This violates project policy in CLAUDE.md"
echo " Remove 'Co-Authored-By: Claude' and similar references"
exit 1
fi
+4 -2
View File
@@ -356,8 +356,10 @@ export default function CalendarPage() {
const handleHoverLeave = useCallback(() => { const handleHoverLeave = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
setDetailEvent(null); hoverTimerRef.current = setTimeout(() => {
setDetailAnchorRect(null); setDetailEvent(null);
setDetailAnchorRect(null);
}, 300);
}, []); }, []);
const handleEditFromDetail = useCallback(() => { const handleEditFromDetail = useCallback(() => {
+36 -8
View File
@@ -3145,10 +3145,9 @@ export class JMAPClient implements IJMAPClient {
filter, filter,
limit: limit || 1000, limit: limit || 1000,
}; };
// Expand recurring events into individual occurrences when a date range is provided // NOTE: We do NOT use expandRecurrences because Stalwart returns synthetic
if (filter.after || filter.before) { // IDs that cannot be used for CalendarEvent/set (update/destroy).
queryArgs.expandRecurrences = true; // Recurrence expansion is done client-side instead.
}
if (sort) { if (sort) {
queryArgs.sort = sort; queryArgs.sort = sort;
} }
@@ -3206,6 +3205,7 @@ export class JMAPClient implements IJMAPClient {
accountId, accountId,
sendSchedulingMessages, sendSchedulingMessages,
event: getCalendarEventDebugSnapshot(cleanEvent), event: getCalendarEventDebugSnapshot(cleanEvent),
eventKeys: Object.keys(cleanEvent),
}); });
const setArgs: Record<string, unknown> = { const setArgs: Record<string, unknown> = {
@@ -3230,6 +3230,8 @@ export class JMAPClient implements IJMAPClient {
if (result.notCreated?.["new-event"]) { if (result.notCreated?.["new-event"]) {
const error = result.notCreated["new-event"]; const error = result.notCreated["new-event"];
debug.warn('CalendarEvent/create notCreated', error); debug.warn('CalendarEvent/create notCreated', error);
debug.warn('CalendarEvent/create invalid properties', error.properties);
debug.warn('CalendarEvent/create sent keys', Object.keys(cleanEvent));
debug.groupEnd(); debug.groupEnd();
throw new Error(error.description || "Failed to create calendar event"); throw new Error(error.description || "Failed to create calendar event");
} }
@@ -3295,20 +3297,33 @@ export class JMAPClient implements IJMAPClient {
setArgs.sendSchedulingMessages = sendSchedulingMessages; setArgs.sendSchedulingMessages = sendSchedulingMessages;
} }
debug.log('CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages });
const response = await this.request([ const response = await this.request([
["CalendarEvent/set", setArgs, "0"] ["CalendarEvent/set", setArgs, "0"]
], this.calendarUsing()); ], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const methodName = response.methodResponses?.[0]?.[0];
const result = response.methodResponses[0][1]; const result = response.methodResponses?.[0]?.[1];
if (methodName === "error") {
const errorType = result?.type || 'unknown';
const errorDesc = result?.description || '';
debug.error('CalendarEvent/set update returned JMAP error', { type: errorType, description: errorDesc });
throw new Error(`JMAP error (${errorType}): ${errorDesc}`);
}
if (methodName === "CalendarEvent/set") {
if (result.notUpdated?.[eventId]) { if (result.notUpdated?.[eventId]) {
const error = result.notUpdated[eventId]; const error = result.notUpdated[eventId];
debug.error('CalendarEvent/set notUpdated', { eventId, error });
throw new Error(error.description || "Failed to update calendar event"); throw new Error(error.description || "Failed to update calendar event");
} }
debug.log('CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null });
return; return;
} }
debug.error('CalendarEvent/set update unexpected response', { methodName, result });
throw new Error("Failed to update calendar event"); throw new Error("Failed to update calendar event");
} }
@@ -3355,20 +3370,33 @@ export class JMAPClient implements IJMAPClient {
setArgs.sendSchedulingMessages = sendSchedulingMessages; setArgs.sendSchedulingMessages = sendSchedulingMessages;
} }
debug.log('CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages });
const response = await this.request([ const response = await this.request([
["CalendarEvent/set", setArgs, "0"] ["CalendarEvent/set", setArgs, "0"]
], this.calendarUsing()); ], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const methodName = response.methodResponses?.[0]?.[0];
const result = response.methodResponses[0][1]; const result = response.methodResponses?.[0]?.[1];
if (methodName === "error") {
const errorType = result?.type || 'unknown';
const errorDesc = result?.description || '';
debug.error('CalendarEvent/set destroy returned JMAP error', { type: errorType, description: errorDesc });
throw new Error(`JMAP error (${errorType}): ${errorDesc}`);
}
if (methodName === "CalendarEvent/set") {
if (result.notDestroyed?.[eventId]) { if (result.notDestroyed?.[eventId]) {
const error = result.notDestroyed[eventId]; const error = result.notDestroyed[eventId];
debug.error('CalendarEvent/set notDestroyed', { eventId, error });
throw new Error(error.description || "Failed to delete calendar event"); throw new Error(error.description || "Failed to delete calendar event");
} }
debug.log('CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed });
return; return;
} }
debug.error('CalendarEvent/set destroy unexpected response', { methodName, result });
throw new Error("Failed to delete calendar event"); throw new Error("Failed to delete calendar event");
} }
+332
View File
@@ -0,0 +1,332 @@
/**
* Client-side recurrence expansion for JSCalendar events.
*
* Stalwart does not yet support mutations on the synthetic IDs produced by
* CalendarEvent/query?expandRecurrences=true, so we fetch raw events (with
* real, mutable IDs) and expand recurring series into individual occurrences
* in the browser.
*/
import { parseISO, format, addDays, addWeeks, addMonths, addYears } from 'date-fns';
import type { CalendarEvent, CalendarRecurrenceRule } from '@/lib/jmap/types';
const DAY_INDEX: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
/**
* Given a master event and a date range, return an array of "virtual" occurrence
* events. Each occurrence carries a synthetic `id` (for dedup in React) and a
* `masterEventId` field that points back to the real server-side ID so the
* store can use it for mutations.
*
* Non-recurring events are returned as-is. For recurring events the master is
* **not** returned — only expanded instances within the range.
*/
export function expandRecurringEvents(
events: CalendarEvent[],
rangeStart: string,
rangeEnd: string,
): CalendarEvent[] {
const start = parseISO(rangeStart);
const end = parseISO(rangeEnd);
const result: CalendarEvent[] = [];
for (const event of events) {
if (!event.recurrenceRules?.length) {
// Non-recurring event — pass through unchanged
result.push(event);
continue;
}
// Expand each recurrence rule
const occurrences = expandEvent(event, start, end);
result.push(...occurrences);
}
return result;
}
function expandEvent(
master: CalendarEvent,
rangeStart: Date,
rangeEnd: Date,
): CalendarEvent[] {
const eventStart = parseISO(master.start);
if (isNaN(eventStart.getTime())) return [];
const rules = master.recurrenceRules || [];
const overrides = master.recurrenceOverrides || {};
const occurrences: CalendarEvent[] = [];
const seenDates = new Set<string>();
// Process each rule
for (const rule of rules) {
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
for (const date of dates) {
const dateKey = master.showWithoutTime
? format(date, 'yyyy-MM-dd')
: date.toISOString();
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
const recurrenceId = master.showWithoutTime
? format(date, "yyyy-MM-dd'T'00:00:00")
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
const override = overrides[recurrenceId] as (Partial<CalendarEvent> & { excluded?: boolean }) | undefined;
if (override?.excluded) continue;
const occurrence = createOccurrence(master, date, recurrenceId, override);
occurrences.push(occurrence);
}
}
// Also add any overrides that add new dates (not generated by rules)
for (const [recurrenceId, rawOverride] of Object.entries(overrides)) {
const override = rawOverride as Partial<CalendarEvent> & { excluded?: boolean };
if (override.excluded) continue;
const overrideDate = parseISO(recurrenceId);
if (isNaN(overrideDate.getTime())) continue;
if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue;
const dateKey = master.showWithoutTime
? format(overrideDate, 'yyyy-MM-dd')
: overrideDate.toISOString();
if (seenDates.has(dateKey)) continue;
seenDates.add(dateKey);
const occurrence = createOccurrence(master, overrideDate, recurrenceId, override);
occurrences.push(occurrence);
}
return occurrences;
}
function createOccurrence(
master: CalendarEvent,
date: Date,
recurrenceId: string,
override?: Partial<CalendarEvent>,
): CalendarEvent {
const startStr = master.showWithoutTime
? format(date, "yyyy-MM-dd'T'00:00:00")
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
return {
...master,
...(override || {}),
id: `${master.id}:${recurrenceId}`,
originalId: master.originalId || master.id,
uid: master.uid,
calendarIds: master.calendarIds,
start: (override?.start) || startStr,
recurrenceId,
// Remove recurrence rules from instances — only the master has them
recurrenceRules: master.recurrenceRules,
recurrenceOverrides: master.recurrenceOverrides,
excludedRecurrenceRules: master.excludedRecurrenceRules,
};
}
/**
* Generate occurrence dates for a single recurrence rule within a range.
* Capped at 500 occurrences to prevent runaway loops.
*/
function generateDates(
eventStart: Date,
rule: CalendarRecurrenceRule,
rangeStart: Date,
rangeEnd: Date,
): Date[] {
const dates: Date[] = [];
const interval = rule.interval || 1;
const limit = rule.count || 500;
const until = rule.until ? parseISO(rule.until) : null;
let count = 0;
let current = new Date(eventStart);
// Safety: don't generate more than 500 occurrences
const maxIterations = 2000;
let iterations = 0;
while (iterations++ < maxIterations) {
if (count >= limit) break;
if (until && current > until) break;
if (current >= rangeEnd) break;
// For weekly rules with byDay, expand into multiple days per week
if (rule.frequency === 'weekly' && rule.byDay?.length) {
const weekDates = expandByDay(current, rule.byDay, eventStart);
for (const wd of weekDates) {
if (until && wd > until) break;
if (count >= limit) break;
if (wd >= rangeEnd) break;
count++;
if (wd >= rangeStart) {
dates.push(wd);
}
}
} else if (rule.frequency === 'monthly' && rule.byDay?.length) {
const monthDates = expandByDayMonthly(current, rule.byDay);
for (const md of monthDates) {
if (until && md > until) break;
if (count >= limit) break;
if (md >= rangeEnd) break;
count++;
if (md >= rangeStart) {
dates.push(md);
}
}
} else if (rule.frequency === 'monthly' && rule.byMonthDay?.length) {
for (const day of rule.byMonthDay) {
const d = new Date(current);
d.setDate(day);
if (d.getMonth() !== current.getMonth()) continue; // skip invalid days (e.g., Feb 30)
if (until && d > until) break;
if (count >= limit) break;
count++;
if (d >= rangeStart && d < rangeEnd) {
dates.push(d);
}
}
} else if (rule.frequency === 'yearly' && rule.byMonth?.length) {
for (const monthStr of rule.byMonth) {
const month = parseInt(monthStr, 10) - 1; // JSCalendar months are 1-indexed
const d = new Date(current);
d.setMonth(month);
if (rule.byMonthDay?.length) {
for (const day of rule.byMonthDay) {
const dd = new Date(d);
dd.setDate(day);
if (dd.getMonth() !== month) continue;
if (until && dd > until) break;
if (count >= limit) break;
count++;
if (dd >= rangeStart && dd < rangeEnd) {
dates.push(dd);
}
}
} else {
if (until && d > until) break;
if (count >= limit) break;
count++;
if (d >= rangeStart && d < rangeEnd) {
dates.push(d);
}
}
}
} else {
count++;
if (current >= rangeStart && current < rangeEnd) {
dates.push(new Date(current));
}
}
current = advanceDate(current, rule.frequency, interval);
}
return dates;
}
function advanceDate(
date: Date,
frequency: CalendarRecurrenceRule['frequency'],
interval: number,
): Date {
switch (frequency) {
case 'daily': return addDays(date, interval);
case 'weekly': return addWeeks(date, interval);
case 'monthly': return addMonths(date, interval);
case 'yearly': return addYears(date, interval);
case 'hourly': return new Date(date.getTime() + interval * 3600000);
case 'minutely': return new Date(date.getTime() + interval * 60000);
case 'secondly': return new Date(date.getTime() + interval * 1000);
default: return addDays(date, interval);
}
}
function expandByDay(
weekStart: Date,
byDay: { day: string; nthOfPeriod?: number }[],
eventStart: Date,
): Date[] {
const dates: Date[] = [];
const baseDay = weekStart.getDay();
for (const { day } of byDay) {
const targetDay = DAY_INDEX[day];
if (targetDay === undefined) continue;
let diff = targetDay - baseDay;
if (diff < 0) diff += 7;
const d = addDays(weekStart, diff);
// Preserve time from the original event
d.setHours(eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds());
dates.push(d);
}
return dates.sort((a, b) => a.getTime() - b.getTime());
}
function expandByDayMonthly(
monthStart: Date,
byDay: { day: string; nthOfPeriod?: number }[],
): Date[] {
const dates: Date[] = [];
const year = monthStart.getFullYear();
const month = monthStart.getMonth();
for (const { day, nthOfPeriod } of byDay) {
const targetDay = DAY_INDEX[day];
if (targetDay === undefined) continue;
if (nthOfPeriod) {
// Find the nth occurrence of this weekday in the month
const d = nthWeekdayOfMonth(year, month, targetDay, nthOfPeriod);
if (d) {
d.setHours(monthStart.getHours(), monthStart.getMinutes(), monthStart.getSeconds());
dates.push(d);
}
} else {
// Every occurrence of this weekday in the month
let d = new Date(year, month, 1);
while (d.getDay() !== targetDay) {
d = addDays(d, 1);
}
while (d.getMonth() === month) {
const occ = new Date(d);
occ.setHours(monthStart.getHours(), monthStart.getMinutes(), monthStart.getSeconds());
dates.push(occ);
d = addDays(d, 7);
}
}
}
return dates.sort((a, b) => a.getTime() - b.getTime());
}
function nthWeekdayOfMonth(
year: number,
month: number,
weekday: number,
nth: number,
): Date | null {
if (nth > 0) {
// Forward from start of month
let d = new Date(year, month, 1);
while (d.getDay() !== weekday) {
d = addDays(d, 1);
}
d = addDays(d, (nth - 1) * 7);
return d.getMonth() === month ? d : null;
} else {
// Backward from end of month
let d = new Date(year, month + 1, 0); // last day of month
while (d.getDay() !== weekday) {
d = addDays(d, -1);
}
if (nth < -1) {
d = addDays(d, (nth + 1) * 7);
}
return d.getMonth() === month ? d : null;
}
}
+34 -144
View File
@@ -5,6 +5,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/ty
import { debug } from '@/lib/debug'; import { debug } from '@/lib/debug';
import { normalizeAllDayDuration } from '@/lib/calendar-utils'; import { normalizeAllDayDuration } from '@/lib/calendar-utils';
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks'; export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks';
@@ -190,13 +191,17 @@ export const useCalendarStore = create<CalendarStore>()(
before: end, before: end,
}); });
// Filter out malformed events missing required 'start' field // Filter out malformed events missing required 'start' field
const events = rawEvents.filter(e => typeof e.start === 'string' && e.start); const validEvents = rawEvents.filter(e => typeof e.start === 'string' && e.start);
const droppedEvents = rawEvents.length - events.length; const droppedEvents = rawEvents.length - validEvents.length;
// Expand recurring events client-side (Stalwart doesn't support
// mutations on synthetic IDs from server-side expandRecurrences)
const events = expandRecurringEvents(validEvents, start, end);
debug.log('Calendar fetchEvents completed', { debug.log('Calendar fetchEvents completed', {
start, start,
end, end,
rawCount: rawEvents.length, rawCount: rawEvents.length,
usableCount: events.length, validCount: validEvents.length,
expandedCount: events.length,
droppedEvents, droppedEvents,
}); });
if (droppedEvents > 0) { if (droppedEvents > 0) {
@@ -291,10 +296,18 @@ export const useCalendarStore = create<CalendarStore>()(
updateEvent: async (client, id, updates, sendSchedulingMessages) => { updateEvent: async (client, id, updates, sendSchedulingMessages) => {
set({ error: null }); set({ error: null });
try { try {
// Resolve shared event IDs // Resolve shared event IDs and client-side expanded occurrence IDs
const storeEvent = get().events.find(e => e.id === id); const storeEvent = get().events.find(e => e.id === id);
const realId = storeEvent?.originalId || id; const realId = storeEvent?.originalId || id;
const targetAccountId = storeEvent?.accountId; const targetAccountId = storeEvent?.accountId;
debug.log('Calendar updateEvent', {
storeId: id,
realId,
uid: storeEvent?.uid,
recurrenceId: storeEvent?.recurrenceId,
targetAccountId,
updateKeys: Object.keys(updates),
});
// Remap namespaced calendarIds back to original IDs // Remap namespaced calendarIds back to original IDs
const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates }); const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates });
if (cleanUpdates.calendarIds) { if (cleanUpdates.calendarIds) {
@@ -305,52 +318,7 @@ export const useCalendarStore = create<CalendarStore>()(
} }
cleanUpdates.calendarIds = remapped; cleanUpdates.calendarIds = remapped;
} }
try { await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId);
} catch (updateError) {
// Stalwart rejects updates to "synthetic" JMAP IDs (CalDAV-created events
// or expanded recurring-event instances returned by expandRecurrences).
// Resolve the real event via a UID query and retry.
const message = updateError instanceof Error ? updateError.message : '';
if (message.toLowerCase().includes('synthetic') && storeEvent) {
debug.log('Event has synthetic ID, resolving real ID via UID query');
const queryResults = await client.queryCalendarEvents(
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
);
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
if (realEvent) {
const resolvedId = realEvent.originalId || realEvent.id;
if (storeEvent.recurrenceId) {
// Recurring instance: patch the master event's recurrenceOverrides.
// Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1
const escapedRecurrenceId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1');
// Build override object with all changed properties
const overrideObj: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cleanUpdates as Record<string, unknown>)) {
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
overrideObj[key] = value;
}
const patchUpdates: Record<string, unknown> = {
[`recurrenceOverrides/${escapedRecurrenceId}`]: overrideObj,
};
await client.updateCalendarEvent(
resolvedId,
patchUpdates as unknown as Partial<CalendarEvent>,
sendSchedulingMessages,
targetAccountId
);
} else {
// Non-recurring event with synthetic ID: retry with the real ID
await client.updateCalendarEvent(resolvedId, cleanUpdates, sendSchedulingMessages, targetAccountId);
}
set((state) => ({
events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e),
}));
return;
}
}
throw updateError;
}
set((state) => ({ set((state) => ({
events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e), events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e),
})); }));
@@ -370,7 +338,7 @@ export const useCalendarStore = create<CalendarStore>()(
throw new Error('Invalid participant ID'); throw new Error('Invalid participant ID');
} }
try { try {
// Resolve shared event IDs // Resolve shared event IDs and client-side expanded occurrence IDs
const storeEvent = get().events.find(e => e.id === eventId); const storeEvent = get().events.find(e => e.id === eventId);
const realId = storeEvent?.originalId || eventId; const realId = storeEvent?.originalId || eventId;
const targetAccountId = storeEvent?.accountId; const targetAccountId = storeEvent?.accountId;
@@ -383,65 +351,12 @@ export const useCalendarStore = create<CalendarStore>()(
if (replyTo) { if (replyTo) {
patch.replyTo = replyTo; patch.replyTo = replyTo;
} }
try { await client.updateCalendarEvent(
await client.updateCalendarEvent( realId,
realId, patch as unknown as Partial<CalendarEvent>,
patch as unknown as Partial<CalendarEvent>, true,
true, targetAccountId
targetAccountId );
);
} catch (updateError) {
// Stalwart rejects updates to synthetic IDs. Resolve real ID via UID query.
const message = updateError instanceof Error ? updateError.message : '';
if (message.toLowerCase().includes('synthetic') && storeEvent) {
debug.log('RSVP: Event has synthetic ID, resolving real ID via UID query');
const queryResults = await client.queryCalendarEvents(
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
);
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
if (realEvent) {
const resolvedId = realEvent.originalId || realEvent.id;
if (storeEvent.recurrenceId) {
// Recurring instance: patch RSVP as recurrence override on master
// Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1
const escapedRecId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1');
const overrideObj: Record<string, unknown> = {
[patchKey]: status,
};
if (replyTo) {
overrideObj['replyTo'] = replyTo;
}
const overridePatch: Record<string, unknown> = {
[`recurrenceOverrides/${escapedRecId}`]: overrideObj,
};
await client.updateCalendarEvent(
resolvedId,
overridePatch as unknown as Partial<CalendarEvent>,
true,
targetAccountId
);
} else {
// Non-recurring event: retry RSVP with real ID
await client.updateCalendarEvent(
resolvedId,
patch as unknown as Partial<CalendarEvent>,
true,
targetAccountId
);
}
set((state) => ({
events: state.events.map(e => e.id === eventId ? { ...e, participants: {
...e.participants,
...(e.participants?.[participantId] ? {
[participantId]: { ...e.participants[participantId], participationStatus: status as CalendarParticipant['participationStatus'] },
} : {}),
}} : e),
}));
return;
}
}
throw updateError;
}
set((state) => ({ set((state) => ({
events: state.events.map(e => { events: state.events.map(e => {
if (e.id !== eventId || !e.participants?.[participantId]) return e; if (e.id !== eventId || !e.participants?.[participantId]) return e;
@@ -572,7 +487,7 @@ export const useCalendarStore = create<CalendarStore>()(
deleteEvent: async (client, id, sendSchedulingMessages) => { deleteEvent: async (client, id, sendSchedulingMessages) => {
set({ error: null }); set({ error: null });
try { try {
// Resolve shared event IDs // Resolve shared event IDs and client-side expanded occurrence IDs
const storeEvent = get().events.find(e => e.id === id); const storeEvent = get().events.find(e => e.id === id);
const realId = storeEvent?.originalId || id; const realId = storeEvent?.originalId || id;
const targetAccountId = storeEvent?.accountId; const targetAccountId = storeEvent?.accountId;
@@ -586,39 +501,14 @@ export const useCalendarStore = create<CalendarStore>()(
debug.error('Failed to send cancellation emails:', e); debug.error('Failed to send cancellation emails:', e);
} }
} }
try { debug.log('Calendar deleteEvent', {
await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId); storeId: id,
} catch (deleteError) { realId,
// Stalwart rejects deletes on synthetic IDs (CalDAV-created events or uid: storeEvent?.uid,
// expanded recurring instances). Resolve the real ID via UID query. recurrenceId: storeEvent?.recurrenceId,
const message = deleteError instanceof Error ? deleteError.message : ''; targetAccountId,
if (message.toLowerCase().includes('synthetic') && storeEvent) { });
debug.log('Event has synthetic ID, resolving real ID via UID query for delete'); await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId);
const queryResults = await client.queryCalendarEvents(
{ uid: storeEvent.uid }, undefined, undefined, targetAccountId
);
const realEvent = queryResults.find(e => !e.recurrenceId) || queryResults[0];
if (realEvent) {
const resolvedId = realEvent.originalId || realEvent.id;
if (storeEvent.recurrenceId) {
// Recurring instance: exclude via recurrenceOverrides on master
await client.updateCalendarEvent(
resolvedId,
{ [`recurrenceOverrides/${storeEvent.recurrenceId}`]: { excluded: true } } as unknown as Partial<CalendarEvent>,
false,
targetAccountId
);
} else {
// Non-recurring event: delete using the real ID
await client.deleteCalendarEvent(resolvedId, sendSchedulingMessages, targetAccountId);
}
} else {
throw deleteError;
}
} else {
throw deleteError;
}
}
set((state) => ({ set((state) => ({
events: state.events.filter(e => e.id !== id), events: state.events.filter(e => e.id !== id),
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId, selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,