From 75b5d31414c9cef7b2a6cb6ab3560a276c17eb10 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:12:30 +0200 Subject: [PATCH] feat: implement client-side recurrence expansion for calendar events --- .husky/commit-msg | 7 - app/[locale]/calendar/page.tsx | 6 +- lib/jmap/client.ts | 44 ++++- lib/recurrence-expansion.ts | 332 +++++++++++++++++++++++++++++++++ stores/calendar-store.ts | 178 ++++-------------- 5 files changed, 406 insertions(+), 161 deletions(-) delete mode 100755 .husky/commit-msg create mode 100644 lib/recurrence-expansion.ts diff --git a/.husky/commit-msg b/.husky/commit-msg deleted file mode 100755 index 94864ce0..00000000 --- a/.husky/commit-msg +++ /dev/null @@ -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 diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 712cc606..19fce4dd 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -356,8 +356,10 @@ export default function CalendarPage() { const handleHoverLeave = useCallback(() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } - setDetailEvent(null); - setDetailAnchorRect(null); + hoverTimerRef.current = setTimeout(() => { + setDetailEvent(null); + setDetailAnchorRect(null); + }, 300); }, []); const handleEditFromDetail = useCallback(() => { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index d4ac370a..a3b74002 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -3145,10 +3145,9 @@ export class JMAPClient implements IJMAPClient { filter, limit: limit || 1000, }; - // Expand recurring events into individual occurrences when a date range is provided - if (filter.after || filter.before) { - queryArgs.expandRecurrences = true; - } + // NOTE: We do NOT use expandRecurrences because Stalwart returns synthetic + // IDs that cannot be used for CalendarEvent/set (update/destroy). + // Recurrence expansion is done client-side instead. if (sort) { queryArgs.sort = sort; } @@ -3206,6 +3205,7 @@ export class JMAPClient implements IJMAPClient { accountId, sendSchedulingMessages, event: getCalendarEventDebugSnapshot(cleanEvent), + eventKeys: Object.keys(cleanEvent), }); const setArgs: Record = { @@ -3230,6 +3230,8 @@ export class JMAPClient implements IJMAPClient { if (result.notCreated?.["new-event"]) { const error = result.notCreated["new-event"]; 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(); throw new Error(error.description || "Failed to create calendar event"); } @@ -3295,20 +3297,33 @@ export class JMAPClient implements IJMAPClient { setArgs.sendSchedulingMessages = sendSchedulingMessages; } + debug.log('CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages }); + const response = await this.request([ ["CalendarEvent/set", setArgs, "0"] ], this.calendarUsing()); - if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { - const result = response.methodResponses[0][1]; + const methodName = response.methodResponses?.[0]?.[0]; + 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]) { const error = result.notUpdated[eventId]; + debug.error('CalendarEvent/set notUpdated', { eventId, error }); 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; } + debug.error('CalendarEvent/set update unexpected response', { methodName, result }); throw new Error("Failed to update calendar event"); } @@ -3355,20 +3370,33 @@ export class JMAPClient implements IJMAPClient { setArgs.sendSchedulingMessages = sendSchedulingMessages; } + debug.log('CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages }); + const response = await this.request([ ["CalendarEvent/set", setArgs, "0"] ], this.calendarUsing()); - if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { - const result = response.methodResponses[0][1]; + const methodName = response.methodResponses?.[0]?.[0]; + 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]) { const error = result.notDestroyed[eventId]; + debug.error('CalendarEvent/set notDestroyed', { eventId, error }); throw new Error(error.description || "Failed to delete calendar event"); } + debug.log('CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed }); return; } + debug.error('CalendarEvent/set destroy unexpected response', { methodName, result }); throw new Error("Failed to delete calendar event"); } diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts new file mode 100644 index 00000000..727b351a --- /dev/null +++ b/lib/recurrence-expansion.ts @@ -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 = { 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(); + + // 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 & { 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 & { 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 { + 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; + } +} diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 8f5c8ca5..81425020 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -5,6 +5,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/ty import { debug } from '@/lib/debug'; import { normalizeAllDayDuration } from '@/lib/calendar-utils'; import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; export type CalendarViewMode = 'month' | 'week' | 'day' | 'agenda' | 'tasks'; @@ -190,13 +191,17 @@ export const useCalendarStore = create()( before: end, }); // Filter out malformed events missing required 'start' field - const events = rawEvents.filter(e => typeof e.start === 'string' && e.start); - const droppedEvents = rawEvents.length - events.length; + const validEvents = rawEvents.filter(e => typeof e.start === 'string' && e.start); + 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', { start, end, rawCount: rawEvents.length, - usableCount: events.length, + validCount: validEvents.length, + expandedCount: events.length, droppedEvents, }); if (droppedEvents > 0) { @@ -291,10 +296,18 @@ export const useCalendarStore = create()( updateEvent: async (client, id, updates, sendSchedulingMessages) => { set({ error: null }); 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 realId = storeEvent?.originalId || id; 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 const cleanUpdates = sanitizeOutgoingCalendarEventData({ ...updates }); if (cleanUpdates.calendarIds) { @@ -305,52 +318,7 @@ export const useCalendarStore = create()( } cleanUpdates.calendarIds = remapped; } - try { - 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 = {}; - for (const [key, value] of Object.entries(cleanUpdates as Record)) { - if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue; - overrideObj[key] = value; - } - const patchUpdates: Record = { - [`recurrenceOverrides/${escapedRecurrenceId}`]: overrideObj, - }; - await client.updateCalendarEvent( - resolvedId, - patchUpdates as unknown as Partial, - 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; - } + await client.updateCalendarEvent(realId, cleanUpdates, sendSchedulingMessages, targetAccountId); set((state) => ({ events: state.events.map(e => e.id === id ? { ...e, ...cleanUpdates } : e), })); @@ -370,7 +338,7 @@ export const useCalendarStore = create()( throw new Error('Invalid participant ID'); } 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 realId = storeEvent?.originalId || eventId; const targetAccountId = storeEvent?.accountId; @@ -383,65 +351,12 @@ export const useCalendarStore = create()( if (replyTo) { patch.replyTo = replyTo; } - try { - await client.updateCalendarEvent( - realId, - patch as unknown as Partial, - true, - 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 = { - [patchKey]: status, - }; - if (replyTo) { - overrideObj['replyTo'] = replyTo; - } - const overridePatch: Record = { - [`recurrenceOverrides/${escapedRecId}`]: overrideObj, - }; - await client.updateCalendarEvent( - resolvedId, - overridePatch as unknown as Partial, - true, - targetAccountId - ); - } else { - // Non-recurring event: retry RSVP with real ID - await client.updateCalendarEvent( - resolvedId, - patch as unknown as Partial, - 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; - } + await client.updateCalendarEvent( + realId, + patch as unknown as Partial, + true, + targetAccountId + ); set((state) => ({ events: state.events.map(e => { if (e.id !== eventId || !e.participants?.[participantId]) return e; @@ -572,7 +487,7 @@ export const useCalendarStore = create()( deleteEvent: async (client, id, sendSchedulingMessages) => { set({ error: null }); 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 realId = storeEvent?.originalId || id; const targetAccountId = storeEvent?.accountId; @@ -586,39 +501,14 @@ export const useCalendarStore = create()( debug.error('Failed to send cancellation emails:', e); } } - try { - await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId); - } catch (deleteError) { - // Stalwart rejects deletes on synthetic IDs (CalDAV-created events or - // expanded recurring instances). Resolve the real ID via UID query. - const message = deleteError instanceof Error ? deleteError.message : ''; - if (message.toLowerCase().includes('synthetic') && storeEvent) { - debug.log('Event has synthetic ID, resolving real ID via UID query for delete'); - 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, - false, - targetAccountId - ); - } else { - // Non-recurring event: delete using the real ID - await client.deleteCalendarEvent(resolvedId, sendSchedulingMessages, targetAccountId); - } - } else { - throw deleteError; - } - } else { - throw deleteError; - } - } + debug.log('Calendar deleteEvent', { + storeId: id, + realId, + uid: storeEvent?.uid, + recurrenceId: storeEvent?.recurrenceId, + targetAccountId, + }); + await client.deleteCalendarEvent(realId, sendSchedulingMessages, targetAccountId); set((state) => ({ events: state.events.filter(e => e.id !== id), selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,