feat: implement client-side recurrence expansion for calendar events
This commit is contained in:
@@ -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
|
|
||||||
@@ -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
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user